sotashimozono_obsidian-remo.../plugin/scripts/start-test-sshd.mjs
Souta c9d465d1d0 fix(test): integration sshd healthcheck (install iproute2 + dump logs on fail)
CI on PR #56 first run came back "sshd entered unhealthy state:
unhealthy". Two issues compounded:

1. The compose healthcheck shells `ss -lnt | grep ':22 '`, but
   `ss` isn't shipped in the minimal `ubuntu:22.04` base — the
   command exited non-zero every interval, so health stayed
   unhealthy past the retry budget. Adding `iproute2` to the
   apt-get install line gives us `ss` (~600 KB add).

2. When the start script gave up, it printed the timeout message
   and exited. We had no visibility into *why* sshd was unhealthy
   because docker logs weren't captured. Added `dumpContainerLogs`
   which calls `docker logs --tail 200` and `docker inspect
   --format '{{json .State.Health}}'` on every failure path so
   the next CI run (or local run) shows the actual sshd output
   and the healthcheck output history without manual digging.
2026-04-27 14:23:59 +09:00

95 lines
3.7 KiB
JavaScript

#!/usr/bin/env node
/**
* Bring up the test sshd container.
*
* - Generates `docker/keys/id_test{,.pub}` if missing (ed25519,
* no passphrase). Both files are gitignored.
* - Runs `docker compose up -d --build` from the repo root.
* - Waits for sshd to accept connections on 127.0.0.1:2222 (using
* the compose healthcheck) before returning.
* - Prints the public key, container name, and host:port so a
* human knows what just happened.
*
* Idempotent: re-running it just re-checks the keys, brings the
* container back up if it was stopped, and waits for healthy.
*
* Used by the integration test suite (`npm run test:integration`).
*/
import { spawnSync, execFileSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, '..', '..');
const keyDir = path.join(repoRoot, 'docker', 'keys');
const keyPath = path.join(keyDir, 'id_test');
const pubPath = `${keyPath}.pub`;
fs.mkdirSync(keyDir, { recursive: true });
fs.mkdirSync(path.join(repoRoot, 'docker', 'test-vault'), { recursive: true });
if (!fs.existsSync(keyPath)) {
console.log(`Generating ed25519 keypair at ${keyPath}`);
// -N '' = no passphrase, -q = no banner. -f overwrites (already
// checked nonexistence above).
spawnRequire('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', keyPath, '-q', '-C', 'obsidian-remote-ssh-test']);
} else {
console.log(`Reusing existing keypair at ${keyPath}`);
}
console.log('Bringing up docker compose service `sshd`…');
spawnRequire('docker', ['compose', 'up', '-d', '--build', 'sshd'], { cwd: repoRoot });
console.log('Waiting for sshd to be healthy…');
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const out = spawnSync('docker', ['inspect', '--format', '{{.State.Health.Status}}',
'obsidian-remote-ssh-test-sshd'], { encoding: 'utf8' });
const status = (out.stdout || '').trim();
if (status === 'healthy') {
console.log('sshd is healthy.');
console.log('');
console.log(` host: 127.0.0.1`);
console.log(` port: 2222`);
console.log(` user: tester`);
console.log(` private key: ${keyPath}`);
console.log(` public key: ${pubPath}`);
console.log('');
process.exit(0);
}
if (status === 'unhealthy' || status === 'exited') {
console.error(`sshd entered unhealthy state: ${status}`);
dumpContainerLogs();
process.exit(1);
}
// 'starting' or empty (container not yet up) — wait a tick.
await new Promise(r => setTimeout(r, 1000));
}
console.error('Timed out waiting for sshd to become healthy.');
dumpContainerLogs();
process.exit(1);
function dumpContainerLogs() {
console.error('--- docker logs (obsidian-remote-ssh-test-sshd) ---');
spawnSync('docker', ['logs', '--tail', '200', 'obsidian-remote-ssh-test-sshd'],
{ stdio: 'inherit' });
console.error('--- docker inspect (Health) ---');
const r = spawnSync('docker',
['inspect', '--format', '{{json .State.Health}}', 'obsidian-remote-ssh-test-sshd'],
{ encoding: 'utf8' });
console.error(r.stdout || r.stderr || '');
}
// ─── helpers ───────────────────────────────────────────────────────────
function spawnRequire(cmd, args, opts = {}) {
const r = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
if (r.status !== 0) {
if (r.error && r.error.code === 'ENOENT') {
console.error(`Missing required tool: ${cmd}. Is it on PATH?`);
}
process.exit(r.status ?? 1);
}
}