@p10i/create-nestbox 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,49 +1,81 @@
1
- import { spawn } from 'node:child_process';
2
- import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { createServer } from 'node:http';
3
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
4
  import { tmpdir } from 'node:os';
4
5
  import { dirname, join, resolve } from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
7
 
7
8
  const fixture = await mkdtemp(join(tmpdir(), 'nestbox-host-runner-'));
9
+ const tokenFixture = await mkdtemp(join(tmpdir(), 'nestbox-host-token-'));
8
10
  const testDirectory = dirname(fileURLToPath(import.meta.url));
9
11
  const runnerPath = resolve(testDirectory, '..', 'host-runner.mjs');
10
- const queue = `${fixture}-queue`;
11
- const requests = join(queue, 'requests');
12
- const running = join(queue, 'running');
13
- const results = join(queue, 'results');
12
+ const token = 'test-token-'.padEnd(64, 'x');
13
+ const jobs = [];
14
+ const results = new Map();
15
+ const resultAttempts = new Map();
14
16
  let runner;
15
- let stdout = '';
17
+ let runnerStdout = '';
18
+ let heartbeatConflicts = 1;
16
19
 
17
- async function waitFor(path, timeout = 10000) {
20
+ function readBody(request) {
21
+ return new Promise((resolveBody, reject) => {
22
+ const chunks = [];
23
+ request.on('data', chunk => chunks.push(chunk));
24
+ request.on('end', () => { try { resolveBody(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); } catch (error) { reject(error); } });
25
+ request.on('error', reject);
26
+ });
27
+ }
28
+
29
+ const server = createServer(async (request, response) => {
30
+ if (request.headers.authorization !== `Bearer ${token}` || !request.headers['x-nestbox-runner-id']) {
31
+ response.writeHead(401).end('{"error":"unauthorized"}'); return;
32
+ }
33
+ if (request.method === 'POST' && request.url === '/_nestbox/host/heartbeat') {
34
+ if (heartbeatConflicts > 0) { heartbeatConflicts -= 1; response.writeHead(409).end('{"error":"another host runner is active"}'); return; }
35
+ await readBody(request); response.writeHead(200, { 'content-type': 'application/json' }).end('{"ok":true}'); return;
36
+ }
37
+ if (request.method === 'GET' && request.url.startsWith('/_nestbox/host/jobs/next')) {
38
+ const job = jobs.shift();
39
+ if (!job) { response.writeHead(204).end(); return; }
40
+ response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(job)); return;
41
+ }
42
+ const match = request.url.match(/^\/_nestbox\/host\/jobs\/([^/]+)\/result$/);
43
+ if (request.method === 'POST' && match) {
44
+ const jobId = decodeURIComponent(match[1]);
45
+ const result = await readBody(request);
46
+ const attempt = (resultAttempts.get(jobId) || 0) + 1;
47
+ resultAttempts.set(jobId, attempt);
48
+ if (jobId === 'job-retry' && attempt === 1) { response.writeHead(503).end('{"error":"restarting"}'); return; }
49
+ results.set(jobId, result);
50
+ response.writeHead(202, { 'content-type': 'application/json' }).end('{"accepted":true}'); return;
51
+ }
52
+ response.writeHead(404).end();
53
+ });
54
+
55
+ async function waitForResult(jobId, timeout = 10000) {
18
56
  const deadline = Date.now() + timeout;
19
57
  while (Date.now() < deadline) {
20
- try { return await readFile(path, 'utf8'); }
21
- catch (error) { if (error?.code !== 'ENOENT') throw error; }
58
+ if (results.has(jobId)) return results.get(jobId);
22
59
  await new Promise(resolveWait => setTimeout(resolveWait, 25));
23
60
  }
24
- throw new Error(`Timed out waiting for ${path}`);
61
+ throw new Error(`Timed out waiting for result ${jobId}`);
25
62
  }
26
63
 
27
- async function waitForEvent(type, requestId) {
28
- const deadline = Date.now() + 10000;
64
+ async function waitForEvent(type, requestId, timeout = 10000) {
65
+ const deadline = Date.now() + timeout;
29
66
  while (Date.now() < deadline) {
30
- for (const line of stdout.split(/\r?\n/).filter(Boolean)) {
67
+ for (const line of runnerStdout.split(/\r?\n/).filter(Boolean)) {
31
68
  const event = JSON.parse(line);
32
69
  if (event.type === type && (!requestId || event.requestId === requestId)) return event;
33
70
  }
34
71
  await new Promise(resolveWait => setTimeout(resolveWait, 25));
35
72
  }
36
- throw new Error(`Timed out waiting for ${type}`);
73
+ throw new Error(`Timed out waiting for event ${type}`);
37
74
  }
38
75
 
39
- async function submit(job) {
40
- await writeFile(join(requests, `${job.jobId}.json`), JSON.stringify({ schemaVersion: 1, ...job }));
41
- return JSON.parse(await waitFor(join(results, `${job.jobId}.json`)));
42
- }
76
+ function submit(job) { jobs.push({ schemaVersion: 1, ...job }); return waitForResult(job.jobId); }
43
77
 
44
78
  try {
45
- await Promise.all([mkdir(requests, { recursive: true }), mkdir(running, { recursive: true })]);
46
- await writeFile(join(running, 'interrupted-job.json'), JSON.stringify({ schemaVersion: 1, jobId: 'interrupted-job', type: 'npm.run', script: 'ok', args: [] }));
47
79
  await writeFile(join(fixture, 'package.json'), JSON.stringify({
48
80
  private: true,
49
81
  scripts: {
@@ -52,52 +84,60 @@ try {
52
84
  fail: `node -e "process.stderr.write('host-failed'); process.exit(7)"`,
53
85
  },
54
86
  }));
87
+ const tokenPath = join(tokenFixture, 'host-token');
88
+ await writeFile(tokenPath, `${token}\n`);
89
+ await new Promise((resolveListen, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolveListen); });
90
+ const port = server.address().port;
55
91
  runner = spawn(process.execPath, [runnerPath, '--json'], {
56
- env: { ...process.env, NESTBOX_HOST_WORKSPACE: fixture, NESTBOX_HOST_QUEUE: queue },
57
- stdio: ['ignore', 'pipe', 'pipe'],
58
- windowsHide: true,
92
+ env: { ...process.env, npm_execpath: '', NESTBOX_HOST_WORKSPACE: fixture, NESTBOX_CONTROL_HOST_URL: `http://127.0.0.1:${port}/_nestbox`, NESTBOX_HOST_TOKEN_FILE: tokenPath, NESTBOX_HOST_TOKEN: '' },
93
+ stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
59
94
  });
60
- let stderr = '';
61
- runner.stdout.on('data', chunk => stdout += chunk);
62
- runner.stderr.on('data', chunk => stderr += chunk);
63
- await waitFor(join(queue, 'heartbeat.json'));
64
- const interrupted = JSON.parse(await waitFor(join(results, 'interrupted-job.json')));
65
- if (!interrupted.interrupted || interrupted.exitCode !== 1) throw new Error('Interrupted job was replayed instead of rejected.');
95
+ let runnerStderr = '';
96
+ runner.stdout.on('data', chunk => runnerStdout += chunk); runner.stderr.on('data', chunk => runnerStderr += chunk);
97
+ await waitForEvent('runner.ready');
66
98
 
67
99
  const success = await submit({ jobId: 'job-ok', type: 'npm.run', script: 'ok', args: [] });
68
100
  const failure = await submit({ jobId: 'job-fail', type: 'npm.run', script: 'fail', args: [] });
69
101
  if (success.exitCode !== 0 || !success.stdout.includes('host-ok') || !success.hostPlatform) throw new Error('Successful host result was not preserved.');
70
102
  if (failure.exitCode !== 7 || !failure.stderr.includes('host-failed')) throw new Error('Failed host result was not preserved.');
103
+ const retried = await submit({ jobId: 'job-retry', type: 'npm.run', script: 'ok', args: [] });
104
+ if (retried.exitCode !== 0 || resultAttempts.get('job-retry') !== 2) throw new Error('Host result was not retried after a transient control failure.');
71
105
 
72
106
  const requestId = 'npm-script-request-test-add';
73
- const requested = await submit({ jobId: requestId, type: 'npm.scripts.request', requestId, operation: 'add', name: 'approved', command: `node -e "process.stdout.write('approved')"` });
107
+ const requestedPromise = submit({ jobId: requestId, type: 'npm.scripts.request', requestId, operation: 'add', name: 'approved', command: `node -e "process.stdout.write('approved')"` });
74
108
  const event = await waitForEvent('npm.scripts.confirmation_required', requestId);
109
+ const requested = await requestedPromise;
75
110
  if (requested.status !== 'confirmation_required' || Object.hasOwn(requested, 'code')) throw new Error('Script request exposed its confirmation code.');
76
111
  const confirmed = await submit({ jobId: 'confirm-add', type: 'npm.scripts.confirm', requestId, code: event.code });
77
112
  const changed = JSON.parse(await readFile(join(fixture, 'package.json'), 'utf8'));
78
113
  if (confirmed.status !== 'success' || !changed.scripts.approved) throw new Error('Approved script was not added.');
79
114
 
80
- const raceId = 'npm-script-request-race-test';
81
- await submit({ jobId: raceId, type: 'npm.scripts.request', requestId: raceId, operation: 'edit', name: 'approved', command: 'node changed.mjs' });
82
- const raceEvent = await waitForEvent('npm.scripts.confirmation_required', raceId);
83
- changed.scripts.concurrent = 'node concurrent.mjs';
84
- await writeFile(join(fixture, 'package.json'), JSON.stringify(changed));
85
- const raced = await submit({ jobId: 'confirm-race', type: 'npm.scripts.confirm', requestId: raceId, code: raceEvent.code });
86
- if (raced.exitCode !== 1 || !raced.stderr.includes('changed after approval')) throw new Error('Concurrent package change was overwritten.');
87
-
88
115
  const protectedResult = await submit({ jobId: 'protected-host', type: 'npm.scripts.request', requestId: 'protected-host', operation: 'delete', name: 'host' });
89
116
  if (protectedResult.exitCode !== 1 || !protectedResult.stderr.includes('cannot be changed')) throw new Error('Core host script was not protected.');
90
117
 
91
- const exit = new Promise((resolveExit, reject) => {
92
- runner.on('error', reject);
93
- runner.on('close', code => code === 0 || code === null ? resolveExit() : reject(new Error(`Runner exited ${code}: ${stderr}`)));
118
+ const exit = new Promise((resolveExit, reject) => { runner.on('error', reject); runner.on('close', code => code === 0 || code === null ? resolveExit() : reject(new Error(`Runner exited ${code}: ${runnerStderr}`))); });
119
+ runner.kill('SIGTERM'); await exit; runner = undefined;
120
+
121
+ const onceRunner = spawn(process.execPath, [runnerPath, '--once', '--json'], {
122
+ env: { ...process.env, npm_execpath: '', NESTBOX_HOST_WORKSPACE: fixture, NESTBOX_CONTROL_HOST_URL: `http://127.0.0.1:${port}/_nestbox`, NESTBOX_HOST_TOKEN_FILE: tokenPath, NESTBOX_HOST_TOKEN: '' },
123
+ stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
124
+ });
125
+ const onceExit = await Promise.race([
126
+ new Promise(resolveExit => onceRunner.once('close', resolveExit)),
127
+ new Promise((_, reject) => setTimeout(() => reject(new Error('--once runner did not exit after an empty poll.')), 5000)),
128
+ ]);
129
+ if (onceExit !== 0) throw new Error(`--once runner exited ${onceExit}.`);
130
+
131
+ const unsafeToken = join(fixture, 'unsafe-token');
132
+ await writeFile(unsafeToken, `${token}\n`);
133
+ const unsafe = spawnSync(process.execPath, [runnerPath, '--once'], {
134
+ env: { ...process.env, NESTBOX_HOST_WORKSPACE: fixture, NESTBOX_CONTROL_HOST_URL: `http://127.0.0.1:${port}/_nestbox`, NESTBOX_HOST_TOKEN_FILE: unsafeToken, NESTBOX_HOST_TOKEN: '' }, encoding: 'utf8', windowsHide: true,
94
135
  });
95
- runner.kill('SIGTERM');
96
- await exit;
97
- runner = undefined;
98
- console.log('Host runner tests passed.');
136
+ if (unsafe.status === 0 || !unsafe.stderr.includes('must be outside')) throw new Error('Runner accepted a token inside the workspace.');
137
+ console.log('Host runner HTTP tests passed.');
99
138
  } finally {
100
139
  runner?.kill('SIGTERM');
140
+ await new Promise(resolveClose => server.close(resolveClose));
101
141
  await rm(fixture, { recursive: true, force: true });
102
- await rm(queue, { recursive: true, force: true });
142
+ await rm(tokenFixture, { recursive: true, force: true });
103
143
  }
package/tests/nestbox.ps1 CHANGED
@@ -91,9 +91,11 @@ function Invoke-Http([string]$Path, [string]$HostHeader = '', [string]$Method =
91
91
 
92
92
  try {
93
93
  New-Item -ItemType Directory -Path $TempDirectory | Out-Null
94
- $configuredQueue = [regex]::Match([IO.File]::ReadAllText((Join-Path $Root '.env')), '(?m)^NESTBOX_HOST_QUEUE_PATH=(.+)$').Groups[1].Value.Trim()
95
- if (-not $env:NESTBOX_HOST_QUEUE_PATH -and -not $configuredQueue) { $env:NESTBOX_HOST_QUEUE_PATH = Join-Path $TempDirectory 'host-runner' }
96
- if ($env:NESTBOX_HOST_QUEUE_PATH) { New-Item -ItemType Directory -Path $env:NESTBOX_HOST_QUEUE_PATH -Force | Out-Null }
94
+ $configuredTokenFile = [regex]::Match([IO.File]::ReadAllText((Join-Path $Root '.env')), '(?m)^NESTBOX_HOST_TOKEN_FILE=(.+)$').Groups[1].Value.Trim()
95
+ if (-not $env:NESTBOX_HOST_TOKEN_FILE -and -not $configuredTokenFile) {
96
+ $env:NESTBOX_HOST_TOKEN_FILE = Join-Path $TempDirectory 'host-token'
97
+ [IO.File]::WriteAllText($env:NESTBOX_HOST_TOKEN_FILE, 'test-token-' + ('0' * 54))
98
+ }
97
99
 
98
100
  if (Get-Command docker -ErrorAction SilentlyContinue) { Pass 'docker is available' } else { Fail 'docker is required'; throw 'Missing Docker' }
99
101
  if (Get-Command curl.exe -ErrorAction SilentlyContinue) { Pass 'curl.exe is available' } else { Fail 'curl.exe is required'; throw 'Missing curl.exe' }
@@ -250,10 +252,16 @@ try {
250
252
  $controlId = Get-ContainerId 'control'
251
253
  $controlInspect = @(& docker inspect $controlId 2>$null | ConvertFrom-Json)[0]
252
254
  if ($null -eq $controlInspect.NetworkSettings.Ports.'4088/tcp') { Pass 'Control API is not published on the host' } else { Fail 'Control API is published on the host' }
255
+ $runnerBinding = $controlInspect.NetworkSettings.Ports.'4089/tcp'
256
+ if ($null -eq $runnerBinding) { Pass 'Host runner API is not published separately' } else { Fail 'Host runner API is published separately' }
257
+ $response = Invoke-Http '/_nestbox/host/health'
258
+ if ($response.Status -eq 401) { Pass 'Nginx routes the authenticated host runner API' } else { Fail "Host runner gateway returned HTTP $($response.Status) instead of 401" }
253
259
  $openCodeId = Get-ContainerId 'opencode'
254
260
  $openCodeInspect = @(& docker inspect $openCodeId 2>$null | ConvertFrom-Json)[0]
255
261
  if (@($openCodeInspect.Mounts.Destination) -notcontains '/var/run/docker.sock') { Pass 'OpenCode does not receive the Docker socket' } else { Fail 'OpenCode receives the Docker socket' }
262
+ if (@($openCodeInspect.Mounts.Destination) -notcontains '/run/secrets/nestbox-host-token' -and @($openCodeInspect.Config.Env) -notmatch '^NESTBOX_HOST_TOKEN=') { Pass 'OpenCode cannot read the host runner token' } else { Fail 'OpenCode can read the host runner token' }
256
263
  if (@($controlInspect.Mounts.Destination) -contains '/var/run/docker.sock') { Pass 'Control receives the Docker socket' } else { Fail 'Control does not receive the Docker socket' }
264
+ if (@($controlInspect.Mounts.Destination) -contains '/run/secrets/nestbox-host-token') { Pass 'Control receives the host runner token file' } else { Fail 'Control does not receive the host runner token file' }
257
265
 
258
266
  if ($indexPresent) {
259
267
  $result = Invoke-Compose @('exec', '-T', 'php-fpm', 'php', '-l', '/home/code/index.php') -Quiet
package/tests/nestbox.sh CHANGED
@@ -141,11 +141,8 @@ else
141
141
  fi
142
142
 
143
143
  TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/nestbox-test.XXXXXX")"
144
- configured_queue="$(sed -n 's/^NESTBOX_HOST_QUEUE_PATH=//p' "$ROOT/.env" | tail -n 1)"
145
- if [[ -z "${NESTBOX_HOST_QUEUE_PATH:-}" && -z "$configured_queue" ]]; then
146
- export NESTBOX_HOST_QUEUE_PATH="$TEMP_DIR/host-runner"
147
- fi
148
- if [[ -n "${NESTBOX_HOST_QUEUE_PATH:-}" ]]; then mkdir -p "$NESTBOX_HOST_QUEUE_PATH"; fi
144
+ configured_token_file="$(sed -n 's/^NESTBOX_HOST_TOKEN_FILE=//p' "$ROOT/.env" | tail -n 1)"
145
+ if [[ -z "${NESTBOX_HOST_TOKEN_FILE:-}" && -z "$configured_token_file" ]]; then export NESTBOX_HOST_TOKEN_FILE="$TEMP_DIR/host-token"; printf 'test-token-%054d\n' 0 >"$NESTBOX_HOST_TOKEN_FILE"; fi
149
146
 
150
147
  [[ -f "$ROOT/.env" ]] && pass '.env exists' || fail '.env is missing'
151
148
  [[ ! -d "$ROOT/home/.git" ]] && pass 'Page and configuration tree has no embedded Git repository' || fail 'home/.git must not be included in an installation'
@@ -350,11 +347,18 @@ status="$(http_request '/_nestbox/publish?topics=nestbox-test:public' '' 'POST'
350
347
  control_id="$(container_id control)"
351
348
  control_binding="$(docker inspect --format '{{json (index .NetworkSettings.Ports "4088/tcp")}}' "$control_id" 2>/dev/null || true)"
352
349
  [[ -z "$control_binding" || "$control_binding" == "null" ]] && pass 'Control API is not published on the host' || fail 'Control API is published on the host'
350
+ runner_binding="$(docker inspect --format '{{json (index .NetworkSettings.Ports "4089/tcp")}}' "$control_id" 2>/dev/null || true)"
351
+ [[ -z "$runner_binding" || "$runner_binding" == "null" ]] && pass 'Host runner API is not published separately' || fail 'Host runner API is published separately'
352
+ status="$(http_request '/_nestbox/host/health' 2>/dev/null || printf '000')"
353
+ [[ "$status" == "401" ]] && pass 'Nginx routes the authenticated host runner API' || fail "Host runner gateway returned HTTP $status instead of 401"
353
354
  opencode_id="$(container_id opencode)"
354
355
  opencode_mounts="$(docker inspect --format '{{range .Mounts}}{{println .Source .Destination}}{{end}}' "$opencode_id" 2>/dev/null || true)"
355
356
  grep -Fq '/var/run/docker.sock' <<<"$opencode_mounts" && fail 'OpenCode receives the Docker socket' || pass 'OpenCode does not receive the Docker socket'
357
+ opencode_env="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$opencode_id" 2>/dev/null || true)"
358
+ if ! grep -Fq '/run/secrets/nestbox-host-token' <<<"$opencode_mounts" && ! grep -Eq '^NESTBOX_HOST_TOKEN=' <<<"$opencode_env"; then pass 'OpenCode cannot read the host runner token'; else fail 'OpenCode can read the host runner token'; fi
356
359
  control_mounts="$(docker inspect --format '{{range .Mounts}}{{println .Source .Destination}}{{end}}' "$control_id" 2>/dev/null || true)"
357
360
  grep -Fq '/var/run/docker.sock' <<<"$control_mounts" && pass 'Control receives the Docker socket' || fail 'Control does not receive the Docker socket'
361
+ grep -Fq '/run/secrets/nestbox-host-token' <<<"$control_mounts" && pass 'Control receives the host runner token file' || fail 'Control does not receive the host runner token file'
358
362
 
359
363
  if ((INDEX_PRESENT)); then
360
364
  if compose exec -T php-fpm php -l /home/code/index.php >/dev/null 2>&1; then