@p10i/create-nestbox 0.1.1 → 0.2.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.
- package/.dockerignore +3 -0
- package/.env.example +12 -0
- package/CHANGELOG.md +10 -0
- package/INSTALL.md +19 -3
- package/README.md +10 -2
- package/bin/create-nestbox.js +78 -0
- package/docker/control/Dockerfile +12 -0
- package/docker/control/server.py +454 -0
- package/docker/opencode/Dockerfile +1 -1
- package/docker-compose.yaml +65 -0
- package/home/code/__includes/functions.php +68 -0
- package/home/configs/opencode/control-mcp.js +102 -0
- package/home/configs/opencode/instructions.md +19 -3
- package/home/configs/opencode/opencode.json +11 -0
- package/host-runner.mjs +301 -0
- package/package.host.json +8 -0
- package/package.json +9 -1
- package/scripts/check-package.js +14 -1
- package/template.gitignore +1 -0
- package/tests/control.mjs +51 -0
- package/tests/creator.mjs +42 -0
- package/tests/host-runner.mjs +103 -0
- package/tests/nestbox.ps1 +19 -6
- package/tests/nestbox.sh +22 -5
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const fixture = await mkdtemp(join(tmpdir(), 'nestbox-creator-'));
|
|
7
|
+
|
|
8
|
+
function create(target, layout) {
|
|
9
|
+
const result = spawnSync(process.execPath, [resolve('bin/create-nestbox.js'), target, '--layout', layout, '--yes', '--no-git'], { encoding: 'utf8', env: { ...process.env, NESTBOX_CREATOR_STATE_ROOT: join(fixture, 'state') } });
|
|
10
|
+
if (result.status !== 0) throw new Error(`Creator failed for ${layout}: ${result.stderr || result.stdout}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const nested = join(fixture, 'nested');
|
|
15
|
+
await mkdir(nested);
|
|
16
|
+
await writeFile(join(nested, 'package.json'), JSON.stringify({ name: 'existing', scripts: { build: 'node build.mjs' } }));
|
|
17
|
+
create(nested, 'nestbox');
|
|
18
|
+
const nestedPackage = JSON.parse(await readFile(join(nested, 'package.json'), 'utf8'));
|
|
19
|
+
if (nestedPackage.name !== 'existing' || nestedPackage.scripts.build !== 'node build.mjs') throw new Error('Creator replaced existing package metadata.');
|
|
20
|
+
if (nestedPackage.scripts.host !== 'node .nestbox/host-runner.mjs') throw new Error('Nested host script has the wrong path.');
|
|
21
|
+
const nestedEnv = await readFile(join(nested, '.nestbox', '.env'), 'utf8');
|
|
22
|
+
if (!/^WORKSPACE_PATH=\.\.$/m.test(nestedEnv) || !/^NESTBOX_HOST_QUEUE_PATH=.+$/m.test(nestedEnv)) throw new Error('Nested runtime paths were not configured.');
|
|
23
|
+
const npmCommand = process.env.npm_execpath ? process.execPath : (process.platform === 'win32' ? 'npm.cmd' : 'npm');
|
|
24
|
+
const npmArgs = process.env.npm_execpath ? [process.env.npm_execpath, 'run', 'test:host'] : ['run', 'test:host'];
|
|
25
|
+
const nestedHostTest = spawnSync(npmCommand, npmArgs, { cwd: nested, encoding: 'utf8' });
|
|
26
|
+
if (nestedHostTest.status !== 0) throw new Error(`Nested host test script failed: ${nestedHostTest.error?.message || nestedHostTest.stderr || nestedHostTest.stdout}`);
|
|
27
|
+
|
|
28
|
+
const direct = join(fixture, 'direct');
|
|
29
|
+
create(direct, 'current');
|
|
30
|
+
const directPackage = JSON.parse(await readFile(join(direct, 'package.json'), 'utf8'));
|
|
31
|
+
if (directPackage.scripts.host !== 'node host-runner.mjs' || directPackage.scripts['test:host'] !== 'node tests/host-runner.mjs') throw new Error('Direct host scripts have the wrong paths.');
|
|
32
|
+
|
|
33
|
+
const conflicting = join(fixture, 'conflicting');
|
|
34
|
+
await mkdir(conflicting);
|
|
35
|
+
await writeFile(join(conflicting, 'package.json'), JSON.stringify({ scripts: { host: 'node something-else.mjs' } }));
|
|
36
|
+
const conflict = spawnSync(process.execPath, [resolve('bin/create-nestbox.js'), conflicting, '--layout', 'nestbox', '--yes', '--no-git'], { encoding: 'utf8', env: { ...process.env, NESTBOX_CREATOR_STATE_ROOT: join(fixture, 'state') } });
|
|
37
|
+
if (conflict.status === 0 || !conflict.stderr.includes('Refusing to overwrite')) throw new Error('Creator accepted a conflicting host script.');
|
|
38
|
+
|
|
39
|
+
console.log('Creator tests passed.');
|
|
40
|
+
} finally {
|
|
41
|
+
await rm(fixture, { recursive: true, force: true });
|
|
42
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const fixture = await mkdtemp(join(tmpdir(), 'nestbox-host-runner-'));
|
|
8
|
+
const testDirectory = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
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');
|
|
14
|
+
let runner;
|
|
15
|
+
let stdout = '';
|
|
16
|
+
|
|
17
|
+
async function waitFor(path, timeout = 10000) {
|
|
18
|
+
const deadline = Date.now() + timeout;
|
|
19
|
+
while (Date.now() < deadline) {
|
|
20
|
+
try { return await readFile(path, 'utf8'); }
|
|
21
|
+
catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
22
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 25));
|
|
23
|
+
}
|
|
24
|
+
throw new Error(`Timed out waiting for ${path}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function waitForEvent(type, requestId) {
|
|
28
|
+
const deadline = Date.now() + 10000;
|
|
29
|
+
while (Date.now() < deadline) {
|
|
30
|
+
for (const line of stdout.split(/\r?\n/).filter(Boolean)) {
|
|
31
|
+
const event = JSON.parse(line);
|
|
32
|
+
if (event.type === type && (!requestId || event.requestId === requestId)) return event;
|
|
33
|
+
}
|
|
34
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 25));
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`Timed out waiting for ${type}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
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
|
+
}
|
|
43
|
+
|
|
44
|
+
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
|
+
await writeFile(join(fixture, 'package.json'), JSON.stringify({
|
|
48
|
+
private: true,
|
|
49
|
+
scripts: {
|
|
50
|
+
host: 'node host-runner.mjs',
|
|
51
|
+
ok: `node -e "process.stdout.write('host-ok')"`,
|
|
52
|
+
fail: `node -e "process.stderr.write('host-failed'); process.exit(7)"`,
|
|
53
|
+
},
|
|
54
|
+
}));
|
|
55
|
+
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,
|
|
59
|
+
});
|
|
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.');
|
|
66
|
+
|
|
67
|
+
const success = await submit({ jobId: 'job-ok', type: 'npm.run', script: 'ok', args: [] });
|
|
68
|
+
const failure = await submit({ jobId: 'job-fail', type: 'npm.run', script: 'fail', args: [] });
|
|
69
|
+
if (success.exitCode !== 0 || !success.stdout.includes('host-ok') || !success.hostPlatform) throw new Error('Successful host result was not preserved.');
|
|
70
|
+
if (failure.exitCode !== 7 || !failure.stderr.includes('host-failed')) throw new Error('Failed host result was not preserved.');
|
|
71
|
+
|
|
72
|
+
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')"` });
|
|
74
|
+
const event = await waitForEvent('npm.scripts.confirmation_required', requestId);
|
|
75
|
+
if (requested.status !== 'confirmation_required' || Object.hasOwn(requested, 'code')) throw new Error('Script request exposed its confirmation code.');
|
|
76
|
+
const confirmed = await submit({ jobId: 'confirm-add', type: 'npm.scripts.confirm', requestId, code: event.code });
|
|
77
|
+
const changed = JSON.parse(await readFile(join(fixture, 'package.json'), 'utf8'));
|
|
78
|
+
if (confirmed.status !== 'success' || !changed.scripts.approved) throw new Error('Approved script was not added.');
|
|
79
|
+
|
|
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
|
+
const protectedResult = await submit({ jobId: 'protected-host', type: 'npm.scripts.request', requestId: 'protected-host', operation: 'delete', name: 'host' });
|
|
89
|
+
if (protectedResult.exitCode !== 1 || !protectedResult.stderr.includes('cannot be changed')) throw new Error('Core host script was not protected.');
|
|
90
|
+
|
|
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}`)));
|
|
94
|
+
});
|
|
95
|
+
runner.kill('SIGTERM');
|
|
96
|
+
await exit;
|
|
97
|
+
runner = undefined;
|
|
98
|
+
console.log('Host runner tests passed.');
|
|
99
|
+
} finally {
|
|
100
|
+
runner?.kill('SIGTERM');
|
|
101
|
+
await rm(fixture, { recursive: true, force: true });
|
|
102
|
+
await rm(queue, { recursive: true, force: true });
|
|
103
|
+
}
|
package/tests/nestbox.ps1
CHANGED
|
@@ -91,6 +91,9 @@ 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
97
|
|
|
95
98
|
if (Get-Command docker -ErrorAction SilentlyContinue) { Pass 'docker is available' } else { Fail 'docker is required'; throw 'Missing Docker' }
|
|
96
99
|
if (Get-Command curl.exe -ErrorAction SilentlyContinue) { Pass 'curl.exe is available' } else { Fail 'curl.exe is required'; throw 'Missing curl.exe' }
|
|
@@ -120,13 +123,15 @@ try {
|
|
|
120
123
|
if ($openCodeInstance.Contains('Communication language:')) { Pass 'OpenCode instance policy records communication language' } else { Fail 'OpenCode instance policy omits communication language' }
|
|
121
124
|
$openCodeConfig = [IO.File]::ReadAllText((Join-Path $Root 'home\configs\opencode\opencode.json'))
|
|
122
125
|
if ($openCodeConfig.Contains('"instance.md"')) { Pass 'OpenCode loads instance policy directly' } else { Fail 'OpenCode does not load instance policy' }
|
|
126
|
+
if ($openCodeConfig.Contains('"control"')) { Pass 'OpenCode registers the control MCP' } else { Fail 'OpenCode does not register the control MCP' }
|
|
127
|
+
if ((Test-Path -LiteralPath (Join-Path $Root 'docker\control\server.py')) -and (Test-Path -LiteralPath (Join-Path $Root 'host-runner.mjs'))) { Pass 'Control and host runner sources exist' } else { Fail 'Control or host runner source is missing' }
|
|
123
128
|
|
|
124
129
|
$result = Invoke-Compose @('config', '--quiet') -Quiet
|
|
125
130
|
if ($result.Code -eq 0) { Pass 'Compose configuration is valid' } else { Fail 'Compose configuration is invalid' }
|
|
126
131
|
|
|
127
132
|
$result = Invoke-Compose @('config', '--services') -Quiet
|
|
128
133
|
$services = @($result.Output | ForEach-Object { $_.ToString().Trim() })
|
|
129
|
-
foreach ($service in @('nginx', 'php-fpm', 'rollup', 'opencode')) {
|
|
134
|
+
foreach ($service in @('nginx', 'php-fpm', 'rollup', 'control', 'opencode')) {
|
|
130
135
|
if ($services -contains $service) { Pass "Compose defines $service" } else { Fail "Compose does not define $service" }
|
|
131
136
|
}
|
|
132
137
|
$result = Invoke-Compose @('ps', '--services') -Quiet
|
|
@@ -135,16 +140,16 @@ try {
|
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
if ($Start) {
|
|
138
|
-
$result = Invoke-Compose @('build', 'nginx', 'php-fpm', 'rollup', 'opencode')
|
|
143
|
+
$result = Invoke-Compose @('build', 'nginx', 'php-fpm', 'rollup', 'control', 'opencode')
|
|
139
144
|
if ($result.Code -eq 0) { Pass 'All service images build' } else { Fail 'One or more service images failed to build' }
|
|
140
145
|
|
|
141
146
|
# Do not start the bind-mounted Rollup watcher: it intentionally writes
|
|
142
147
|
# generated bundles. Its image and output are validated separately.
|
|
143
|
-
$result = Invoke-Compose @('up', '-d', 'nginx', 'php-fpm', 'opencode')
|
|
148
|
+
$result = Invoke-Compose @('up', '-d', 'nginx', 'php-fpm', 'control', 'opencode')
|
|
144
149
|
if ($result.Code -eq 0) { Pass 'Application services started' } else { Fail 'Application services failed to start' }
|
|
145
150
|
}
|
|
146
151
|
|
|
147
|
-
foreach ($service in @('nginx', 'php-fpm', 'opencode')) {
|
|
152
|
+
foreach ($service in @('nginx', 'php-fpm', 'control', 'opencode')) {
|
|
148
153
|
if (Test-ContainerRunning $service) { Pass "$service is running" } else { Fail "$service is not running" }
|
|
149
154
|
}
|
|
150
155
|
$rollupId = Get-ContainerId 'rollup'
|
|
@@ -155,7 +160,7 @@ try {
|
|
|
155
160
|
}
|
|
156
161
|
|
|
157
162
|
$restartCounts = @{}
|
|
158
|
-
foreach ($service in @('nginx', 'php-fpm', 'opencode')) { $restartCounts[$service] = Get-RestartCount $service }
|
|
163
|
+
foreach ($service in @('nginx', 'php-fpm', 'control', 'opencode')) { $restartCounts[$service] = Get-RestartCount $service }
|
|
159
164
|
|
|
160
165
|
$result = Invoke-Compose @('port', 'nginx', '80') -Quiet
|
|
161
166
|
$published = ($result.Output -join "`n").Trim()
|
|
@@ -242,6 +247,14 @@ try {
|
|
|
242
247
|
$response = Invoke-Http '/_nestbox/publish?topics=nestbox-test:public' '' 'POST'
|
|
243
248
|
if ($response.Status -eq 404) { Pass 'Publisher endpoint is unavailable on the public port' } else { Fail "Public publisher endpoint returned HTTP $($response.Status)" }
|
|
244
249
|
|
|
250
|
+
$controlId = Get-ContainerId 'control'
|
|
251
|
+
$controlInspect = @(& docker inspect $controlId 2>$null | ConvertFrom-Json)[0]
|
|
252
|
+
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' }
|
|
253
|
+
$openCodeId = Get-ContainerId 'opencode'
|
|
254
|
+
$openCodeInspect = @(& docker inspect $openCodeId 2>$null | ConvertFrom-Json)[0]
|
|
255
|
+
if (@($openCodeInspect.Mounts.Destination) -notcontains '/var/run/docker.sock') { Pass 'OpenCode does not receive the Docker socket' } else { Fail 'OpenCode receives the Docker socket' }
|
|
256
|
+
if (@($controlInspect.Mounts.Destination) -contains '/var/run/docker.sock') { Pass 'Control receives the Docker socket' } else { Fail 'Control does not receive the Docker socket' }
|
|
257
|
+
|
|
245
258
|
if ($indexPresent) {
|
|
246
259
|
$result = Invoke-Compose @('exec', '-T', 'php-fpm', 'php', '-l', '/home/code/index.php') -Quiet
|
|
247
260
|
if ($result.Code -eq 0) { Pass 'Installed super-document passes PHP lint' } else { Fail 'Installed super-document failed PHP lint' }
|
|
@@ -299,7 +312,7 @@ try {
|
|
|
299
312
|
Remove-Job -Job $SseJob -Force -ErrorAction SilentlyContinue
|
|
300
313
|
$SseJob = $null
|
|
301
314
|
|
|
302
|
-
foreach ($service in @('nginx', 'php-fpm', 'opencode')) {
|
|
315
|
+
foreach ($service in @('nginx', 'php-fpm', 'control', 'opencode')) {
|
|
303
316
|
$after = Get-RestartCount $service
|
|
304
317
|
if ((Test-ContainerRunning $service) -and $null -ne $restartCounts[$service] -and $restartCounts[$service] -eq $after) { Pass "$service remained stable during the test" } else { Fail "$service restarted or stopped during the test" }
|
|
305
318
|
}
|
package/tests/nestbox.sh
CHANGED
|
@@ -141,6 +141,11 @@ 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
149
|
|
|
145
150
|
[[ -f "$ROOT/.env" ]] && pass '.env exists' || fail '.env is missing'
|
|
146
151
|
[[ ! -d "$ROOT/home/.git" ]] && pass 'Page and configuration tree has no embedded Git repository' || fail 'home/.git must not be included in an installation'
|
|
@@ -160,6 +165,8 @@ grep -Fq 'deliberately has no Docker socket' "$ROOT/home/configs/opencode/instru
|
|
|
160
165
|
grep -Fq 'Commit policy:' "$ROOT/home/configs/opencode/instance.md" && pass 'OpenCode instance policy records commit behavior' || fail 'OpenCode instance policy omits commit behavior'
|
|
161
166
|
grep -Fq 'Communication language:' "$ROOT/home/configs/opencode/instance.md" && pass 'OpenCode instance policy records communication language' || fail 'OpenCode instance policy omits communication language'
|
|
162
167
|
grep -Fq '"instance.md"' "$ROOT/home/configs/opencode/opencode.json" && pass 'OpenCode loads instance policy directly' || fail 'OpenCode does not load instance policy'
|
|
168
|
+
grep -Fq '"control"' "$ROOT/home/configs/opencode/opencode.json" && pass 'OpenCode registers the control MCP' || fail 'OpenCode does not register the control MCP'
|
|
169
|
+
[[ -f "$ROOT/docker/control/server.py" && -f "$ROOT/host-runner.mjs" ]] && pass 'Control and host runner sources exist' || fail 'Control or host runner source is missing'
|
|
163
170
|
|
|
164
171
|
if compose config --quiet; then
|
|
165
172
|
pass 'Compose configuration is valid'
|
|
@@ -168,7 +175,7 @@ else
|
|
|
168
175
|
fi
|
|
169
176
|
|
|
170
177
|
services="$(compose config --services 2>/dev/null || true)"
|
|
171
|
-
for service in nginx php-fpm rollup opencode; do
|
|
178
|
+
for service in nginx php-fpm rollup control opencode; do
|
|
172
179
|
if grep -qx "$service" <<<"$services"; then
|
|
173
180
|
pass "Compose defines $service"
|
|
174
181
|
else
|
|
@@ -184,7 +191,7 @@ while IFS= read -r service; do
|
|
|
184
191
|
done <<<"$running_services"
|
|
185
192
|
|
|
186
193
|
if ((START)); then
|
|
187
|
-
if compose build nginx php-fpm rollup opencode; then
|
|
194
|
+
if compose build nginx php-fpm rollup control opencode; then
|
|
188
195
|
pass 'All service images build'
|
|
189
196
|
else
|
|
190
197
|
fail 'One or more service images failed to build'
|
|
@@ -193,14 +200,14 @@ if ((START)); then
|
|
|
193
200
|
# The Rollup watcher intentionally writes generated bundles to the bind
|
|
194
201
|
# mount, so lifecycle mode leaves it untouched and validates its image via
|
|
195
202
|
# the build plus the bundles served by Nginx.
|
|
196
|
-
if compose up -d nginx php-fpm opencode; then
|
|
203
|
+
if compose up -d nginx php-fpm control opencode; then
|
|
197
204
|
pass 'Application services started'
|
|
198
205
|
else
|
|
199
206
|
fail 'Application services failed to start'
|
|
200
207
|
fi
|
|
201
208
|
fi
|
|
202
209
|
|
|
203
|
-
for service in nginx php-fpm opencode; do
|
|
210
|
+
for service in nginx php-fpm control opencode; do
|
|
204
211
|
if container_running "$service"; then
|
|
205
212
|
pass "$service is running"
|
|
206
213
|
else
|
|
@@ -217,6 +224,7 @@ fi
|
|
|
217
224
|
NGINX_RESTARTS="$(restart_count nginx 2>/dev/null || printf '')"
|
|
218
225
|
PHP_RESTARTS="$(restart_count php-fpm 2>/dev/null || printf '')"
|
|
219
226
|
OPENCODE_RESTARTS="$(restart_count opencode 2>/dev/null || printf '')"
|
|
227
|
+
CONTROL_RESTARTS="$(restart_count control 2>/dev/null || printf '')"
|
|
220
228
|
|
|
221
229
|
published="$(compose port nginx 80 2>/dev/null || true)"
|
|
222
230
|
if [[ "$published" =~ ^\[(.*)\]:([0-9]+)$ ]]; then
|
|
@@ -339,6 +347,15 @@ fi
|
|
|
339
347
|
status="$(http_request '/_nestbox/publish?topics=nestbox-test:public' '' 'POST' 2>/dev/null || printf '000')"
|
|
340
348
|
[[ "$status" == "404" ]] && pass 'Publisher endpoint is unavailable on the public port' || fail "Public publisher endpoint returned HTTP $status"
|
|
341
349
|
|
|
350
|
+
control_id="$(container_id control)"
|
|
351
|
+
control_binding="$(docker inspect --format '{{json (index .NetworkSettings.Ports "4088/tcp")}}' "$control_id" 2>/dev/null || true)"
|
|
352
|
+
[[ -z "$control_binding" || "$control_binding" == "null" ]] && pass 'Control API is not published on the host' || fail 'Control API is published on the host'
|
|
353
|
+
opencode_id="$(container_id opencode)"
|
|
354
|
+
opencode_mounts="$(docker inspect --format '{{range .Mounts}}{{println .Source .Destination}}{{end}}' "$opencode_id" 2>/dev/null || true)"
|
|
355
|
+
grep -Fq '/var/run/docker.sock' <<<"$opencode_mounts" && fail 'OpenCode receives the Docker socket' || pass 'OpenCode does not receive the Docker socket'
|
|
356
|
+
control_mounts="$(docker inspect --format '{{range .Mounts}}{{println .Source .Destination}}{{end}}' "$control_id" 2>/dev/null || true)"
|
|
357
|
+
grep -Fq '/var/run/docker.sock' <<<"$control_mounts" && pass 'Control receives the Docker socket' || fail 'Control does not receive the Docker socket'
|
|
358
|
+
|
|
342
359
|
if ((INDEX_PRESENT)); then
|
|
343
360
|
if compose exec -T php-fpm php -l /home/code/index.php >/dev/null 2>&1; then
|
|
344
361
|
pass 'Installed super-document passes PHP lint'
|
|
@@ -423,7 +440,7 @@ else
|
|
|
423
440
|
fail 'EventSource did not receive the expected Nchan event'
|
|
424
441
|
fi
|
|
425
442
|
|
|
426
|
-
for entry in "nginx:$NGINX_RESTARTS" "php-fpm:$PHP_RESTARTS" "opencode:$OPENCODE_RESTARTS"; do
|
|
443
|
+
for entry in "nginx:$NGINX_RESTARTS" "php-fpm:$PHP_RESTARTS" "control:$CONTROL_RESTARTS" "opencode:$OPENCODE_RESTARTS"; do
|
|
427
444
|
service="${entry%%:*}"
|
|
428
445
|
before="${entry#*:}"
|
|
429
446
|
after="$(restart_count "$service" 2>/dev/null || printf '')"
|