@celilo/cli 4.1.0 → 5.0.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/AGENTS.md +2 -2
- package/drizzle/0034_private_routes.sql +41 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/src/api/sessions.test.ts +1 -1
- package/src/capabilities/registration.test.ts +11 -11
- package/src/capabilities/secrets.test.ts +18 -18
- package/src/capabilities/validation.test.ts +24 -24
- package/src/cli/cli.test.ts +3 -3
- package/src/cli/commands/module-changeset.test.ts +1 -1
- package/src/cli/commands/module-generate.test.ts +2 -2
- package/src/cli/commands/module-import-aspect.test.ts +2 -2
- package/src/cli/commands/module-update.test.ts +14 -14
- package/src/cli/commands/module-upgrade-gate.test.ts +26 -1
- package/src/cli/commands/module-upgrade.test.ts +3 -3
- package/src/cli/commands/module-upgrade.ts +12 -0
- package/src/cli/commands/module-version.test.ts +1 -1
- package/src/cli/index.ts +22 -2
- package/src/cli/restore-migration-failure.test.ts +1 -1
- package/src/cli/stdout-pipe-flush.test.ts +35 -0
- package/src/db/private-routes.test.ts +159 -0
- package/src/db/schema.ts +49 -0
- package/src/hooks/capability-loader.ts +179 -12
- package/src/hooks/executor.test.ts +13 -13
- package/src/hooks/hook-state-dir.test.ts +2 -2
- package/src/hooks/test-fixtures/store-backed.ts +5 -3
- package/src/manifest/contracts/index.ts +28 -7
- package/src/manifest/contracts/v2.ts +32 -0
- package/src/manifest/icon-schema.test.ts +1 -1
- package/src/manifest/schema.ts +13 -2
- package/src/manifest/template-validator.test.ts +1 -1
- package/src/manifest/validate-privileged.test.ts +1 -1
- package/src/manifest/validate.test.ts +68 -29
- package/src/manifest/validate.ts +14 -1
- package/src/module/import.test.ts +7 -7
- package/src/module/packaging/build.test.ts +1 -1
- package/src/packaging/nfpm-depends.test.ts +42 -0
- package/src/policy/capability-shape-baseline.ts +7 -1
- package/src/policy/fixture-capability-coverage.test.ts +5 -5
- package/src/policy/module-business-baseline.ts +33 -4
- package/src/services/aspect-approvals.test.ts +2 -2
- package/src/services/aspect-reconcile.test.ts +2 -2
- package/src/services/aspect-runner.test.ts +4 -4
- package/src/services/audit/backups.test.ts +1 -1
- package/src/services/audit/capability-abi.test.ts +1 -1
- package/src/services/audit/module-configs.test.ts +1 -1
- package/src/services/build-bus/hook-dispatch-executor.test.ts +3 -3
- package/src/services/bus-interview-park.test.ts +1 -1
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/capability-table-rows.test.ts +20 -2
- package/src/services/celilo-events.test.ts +80 -6
- package/src/services/celilo-events.ts +115 -12
- package/src/services/celilo-mgmt-hooks.test.ts +6 -3
- package/src/services/cross-module-read.test.ts +2 -2
- package/src/services/deploy-validation.test.ts +6 -6
- package/src/services/dns-provider-backfill.test.ts +2 -2
- package/src/services/dns-registrations.test.ts +1 -1
- package/src/services/fleet-checks.test.ts +2 -2
- package/src/services/health-runner.test.ts +1 -1
- package/src/services/infrastructure-variable-resolver.test.ts +11 -11
- package/src/services/instance-ops.test.ts +2 -2
- package/src/services/module-deploy.dns-repoint.test.ts +1 -1
- package/src/services/module-pause-quiescence.test.ts +1 -1
- package/src/services/module-pause.test.ts +1 -1
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/module-types-generator.test.ts +1 -1
- package/src/services/module-validator/contract-version.test.ts +3 -3
- package/src/services/network-ensure.test.ts +1 -1
- package/src/services/proxmox-reconcile.test.ts +2 -2
- package/src/services/remove-guard.test.ts +1 -1
- package/src/services/update/dep-graph.test.ts +1 -1
- package/src/services/update/orchestrator.test.ts +1 -1
- package/src/variables/capability-self-ref.test.ts +2 -2
- package/src/variables/computed/computed-integration.test.ts +1 -1
- package/src/variables/declarative-derivation.test.ts +19 -19
|
@@ -95,7 +95,7 @@ describe('updateOne — displayVersion and quiet', () => {
|
|
|
95
95
|
mkdirSync(srcDir, { recursive: true });
|
|
96
96
|
writeFileSync(
|
|
97
97
|
join(srcDir, 'manifest.yml'),
|
|
98
|
-
`celilo_contract: "
|
|
98
|
+
`celilo_contract: "2.0"
|
|
99
99
|
id: testmod
|
|
100
100
|
name: Test Module
|
|
101
101
|
version: 1.0.0
|
|
@@ -117,7 +117,7 @@ description: fixture
|
|
|
117
117
|
version: '1.0.0+5',
|
|
118
118
|
updatedAt: new Date('2026-04-25T16:26:31Z'),
|
|
119
119
|
manifestData: {
|
|
120
|
-
celilo_contract: '
|
|
120
|
+
celilo_contract: '2.0',
|
|
121
121
|
id: 'testmod',
|
|
122
122
|
name: 'Test Module',
|
|
123
123
|
version: '1.0.0',
|
|
@@ -195,7 +195,7 @@ description: fixture
|
|
|
195
195
|
// row's manifestData should reflect the removal.
|
|
196
196
|
writeFileSync(
|
|
197
197
|
join(srcDir, 'manifest.yml'),
|
|
198
|
-
`celilo_contract: "
|
|
198
|
+
`celilo_contract: "2.0"
|
|
199
199
|
id: testmod
|
|
200
200
|
name: Test Module Renamed
|
|
201
201
|
version: 2.0.0
|
|
@@ -210,7 +210,7 @@ variables:
|
|
|
210
210
|
db.update(modules)
|
|
211
211
|
.set({
|
|
212
212
|
manifestData: {
|
|
213
|
-
celilo_contract: '
|
|
213
|
+
celilo_contract: '2.0',
|
|
214
214
|
id: 'testmod',
|
|
215
215
|
name: 'Test Module',
|
|
216
216
|
version: '1.0.0',
|
|
@@ -249,7 +249,7 @@ variables:
|
|
|
249
249
|
try {
|
|
250
250
|
writeFileSync(
|
|
251
251
|
join(srcDir, 'manifest.yml'),
|
|
252
|
-
`celilo_contract: "
|
|
252
|
+
`celilo_contract: "2.0"
|
|
253
253
|
id: testmod
|
|
254
254
|
name: Test Module
|
|
255
255
|
version: 1.1.0
|
|
@@ -302,7 +302,7 @@ describe('registry sweep — an unanswered breaking update is not a decline', ()
|
|
|
302
302
|
sourcePath: join(tempDir, 'installed'),
|
|
303
303
|
version: '1.0.2+9',
|
|
304
304
|
manifestData: {
|
|
305
|
-
celilo_contract: '
|
|
305
|
+
celilo_contract: '2.0',
|
|
306
306
|
id: 'iptables',
|
|
307
307
|
name: 'iptables',
|
|
308
308
|
version: '1.0.2',
|
|
@@ -376,7 +376,7 @@ describe("updateOne — pointed at the module's own installed path", () => {
|
|
|
376
376
|
mkdirSync(installedDir, { recursive: true });
|
|
377
377
|
writeFileSync(
|
|
378
378
|
join(installedDir, 'manifest.yml'),
|
|
379
|
-
`celilo_contract: "
|
|
379
|
+
`celilo_contract: "2.0"
|
|
380
380
|
id: selfmod
|
|
381
381
|
name: Self Module
|
|
382
382
|
version: 1.0.0
|
|
@@ -392,7 +392,7 @@ description: fixture
|
|
|
392
392
|
sourcePath: installedDir,
|
|
393
393
|
version: '1.0.0',
|
|
394
394
|
manifestData: {
|
|
395
|
-
celilo_contract: '
|
|
395
|
+
celilo_contract: '2.0',
|
|
396
396
|
id: 'selfmod',
|
|
397
397
|
name: 'Self Module',
|
|
398
398
|
version: '1.0.0',
|
|
@@ -480,7 +480,7 @@ describe('updateOne — an update that fails partway leaves the install untouche
|
|
|
480
480
|
mkdirSync(join(installedDir, 'generated'), { recursive: true });
|
|
481
481
|
writeFileSync(
|
|
482
482
|
join(installedDir, 'manifest.yml'),
|
|
483
|
-
'celilo_contract: "
|
|
483
|
+
'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
|
|
484
484
|
);
|
|
485
485
|
writeFileSync(join(installedDir, 'scripts', 'on_install.ts'), 'export const OLD = 1;\n');
|
|
486
486
|
// `derived` — celilo's own output, must survive an update either way.
|
|
@@ -490,7 +490,7 @@ describe('updateOne — an update that fails partway leaves the install untouche
|
|
|
490
490
|
mkdirSync(join(srcDir, 'scripts'), { recursive: true });
|
|
491
491
|
writeFileSync(
|
|
492
492
|
join(srcDir, 'manifest.yml'),
|
|
493
|
-
'celilo_contract: "
|
|
493
|
+
'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 2.0.0\ndescription: fixture\n',
|
|
494
494
|
);
|
|
495
495
|
writeFileSync(join(srcDir, 'scripts', 'on_install.ts'), 'export const NEW = 2;\n');
|
|
496
496
|
// cpSync refuses a FIFO with ENOTSUP. Sorts last, so the real files copy first.
|
|
@@ -504,7 +504,7 @@ describe('updateOne — an update that fails partway leaves the install untouche
|
|
|
504
504
|
sourcePath: installedDir,
|
|
505
505
|
version: '1.0.0',
|
|
506
506
|
manifestData: {
|
|
507
|
-
celilo_contract: '
|
|
507
|
+
celilo_contract: '2.0',
|
|
508
508
|
id: 'testmod',
|
|
509
509
|
name: 'Test Module',
|
|
510
510
|
version: '1.0.0',
|
|
@@ -588,7 +588,7 @@ describe('updateOne — a .netapp package source', () => {
|
|
|
588
588
|
mkdirSync(installedDir, { recursive: true });
|
|
589
589
|
writeFileSync(
|
|
590
590
|
join(installedDir, 'manifest.yml'),
|
|
591
|
-
'celilo_contract: "
|
|
591
|
+
'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
|
|
592
592
|
);
|
|
593
593
|
|
|
594
594
|
db = getDb();
|
|
@@ -599,7 +599,7 @@ describe('updateOne — a .netapp package source', () => {
|
|
|
599
599
|
sourcePath: installedDir,
|
|
600
600
|
version: '1.0.0+5',
|
|
601
601
|
manifestData: {
|
|
602
|
-
celilo_contract: '
|
|
602
|
+
celilo_contract: '2.0',
|
|
603
603
|
id: 'testmod',
|
|
604
604
|
name: 'Test Module',
|
|
605
605
|
version: '1.0.0',
|
|
@@ -626,7 +626,7 @@ describe('updateOne — a .netapp package source', () => {
|
|
|
626
626
|
mkdirSync(srcDir, { recursive: true });
|
|
627
627
|
writeFileSync(
|
|
628
628
|
join(srcDir, 'manifest.yml'),
|
|
629
|
-
'celilo_contract: "
|
|
629
|
+
'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 2.0.0\ndescription: fixture v2\n',
|
|
630
630
|
);
|
|
631
631
|
const pkgPath = join(tempDir, 'testmod-2.0.0.netapp');
|
|
632
632
|
await tarCreate({ file: pkgPath, cwd: srcDir }, ['manifest.yml']);
|
|
@@ -45,7 +45,7 @@ async function packageWithManifest(manifestYaml: string): Promise<ArrayBuffer> {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
const MANIFEST = (publicWebVersion: string) => `
|
|
48
|
-
celilo_contract: "
|
|
48
|
+
celilo_contract: "2.0"
|
|
49
49
|
id: celilo-website
|
|
50
50
|
name: Celilo Website
|
|
51
51
|
version: 2.0.0
|
|
@@ -152,3 +152,28 @@ describe('handleModuleUpgrade — the capability gate (celilo#1361)', () => {
|
|
|
152
152
|
}
|
|
153
153
|
});
|
|
154
154
|
});
|
|
155
|
+
|
|
156
|
+
describe('handleModuleUpgrade — provider AHEAD is terminal (celilo#1414)', () => {
|
|
157
|
+
test('fails, does not defer, when every blocker is major_mismatch_lower', async () => {
|
|
158
|
+
// Consumer requires public_web@3.1.0; the deployed caddy serves 4.0.0.
|
|
159
|
+
// No retry can fix that — the consumer's source has to move.
|
|
160
|
+
seedFleet('4.0.0');
|
|
161
|
+
downloadSpy.mockImplementation(async () => packageWithManifest(MANIFEST('3.1.0')));
|
|
162
|
+
|
|
163
|
+
const result = await handleModuleUpgrade(['celilo-website'], {});
|
|
164
|
+
|
|
165
|
+
expect(result.success).toBe(false);
|
|
166
|
+
if (!result.success) {
|
|
167
|
+
expect(result.deferred).toBeUndefined();
|
|
168
|
+
expect(result.error).not.toContain('retries automatically');
|
|
169
|
+
expect(result.error).toContain('public_web');
|
|
170
|
+
}
|
|
171
|
+
// Still no state change: a terminal blocker must not record the version.
|
|
172
|
+
const row = getDb()
|
|
173
|
+
.select()
|
|
174
|
+
.from(modules)
|
|
175
|
+
.all()
|
|
176
|
+
.find((m) => m.id === 'celilo-website');
|
|
177
|
+
expect(row?.version).toBe('1.0.5');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -180,7 +180,7 @@ describe('upgradeOneModule — a failed deploy records the failure in the row (c
|
|
|
180
180
|
mkdirSync(installedDir, { recursive: true });
|
|
181
181
|
writeFileSync(
|
|
182
182
|
join(installedDir, 'manifest.yml'),
|
|
183
|
-
'celilo_contract: "
|
|
183
|
+
'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
|
|
184
184
|
);
|
|
185
185
|
|
|
186
186
|
db = getDb();
|
|
@@ -193,7 +193,7 @@ describe('upgradeOneModule — a failed deploy records the failure in the row (c
|
|
|
193
193
|
state: 'VERIFIED',
|
|
194
194
|
updatedAt: FROZEN,
|
|
195
195
|
manifestData: {
|
|
196
|
-
celilo_contract: '
|
|
196
|
+
celilo_contract: '2.0',
|
|
197
197
|
id: 'testmod',
|
|
198
198
|
name: 'Test Module',
|
|
199
199
|
version: '1.0.0',
|
|
@@ -208,7 +208,7 @@ describe('upgradeOneModule — a failed deploy records the failure in the row (c
|
|
|
208
208
|
mkdirSync(srcDir, { recursive: true });
|
|
209
209
|
writeFileSync(
|
|
210
210
|
join(srcDir, 'manifest.yml'),
|
|
211
|
-
'celilo_contract: "
|
|
211
|
+
'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture v6\n',
|
|
212
212
|
);
|
|
213
213
|
const pkgPath = join(tempDir, 'testmod-1.0.0.netapp');
|
|
214
214
|
await tarCreate({ file: pkgPath, cwd: srcDir }, ['manifest.yml']);
|
|
@@ -194,6 +194,18 @@ export async function upgradeOneModule(
|
|
|
194
194
|
const blockers = capabilityBlockersForManifest(db, targetManifestCheck.manifest);
|
|
195
195
|
if (blockers.length > 0) {
|
|
196
196
|
const why = blockers.map((b) => b.message).join('; ');
|
|
197
|
+
// A provider AHEAD of what the consumer requires is TERMINAL (celilo#1414):
|
|
198
|
+
// no retry can fix it, the consumer's source must move to the provider's
|
|
199
|
+
// current major and republish. Only a provider BEHIND (major_mismatch_higher,
|
|
200
|
+
// the celilo#1361 case) is transient. Deferring the terminal case retried it
|
|
201
|
+
// forever, reported success, and left four consumers silently stuck.
|
|
202
|
+
if (blockers.every((b) => b.reason === 'major_mismatch_lower')) {
|
|
203
|
+
return {
|
|
204
|
+
success: false,
|
|
205
|
+
error: `${moduleId} upgrade to ${targetVersion} cannot proceed: the deployed providers are AHEAD of its capability requirements: ${why}. This will not resolve on retry — update ${moduleId}'s source to the provider's current major and republish it.`,
|
|
206
|
+
details: blockers,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
197
209
|
return {
|
|
198
210
|
success: false,
|
|
199
211
|
deferred: true,
|
package/src/cli/index.ts
CHANGED
|
@@ -2578,6 +2578,26 @@ function parseCommand(command: string): string[] {
|
|
|
2578
2578
|
return args;
|
|
2579
2579
|
}
|
|
2580
2580
|
|
|
2581
|
+
/**
|
|
2582
|
+
* Write to stdout and WAIT for it to reach the far end before returning.
|
|
2583
|
+
*
|
|
2584
|
+
* `process.stdout.write()` followed by `process.exit()` silently drops
|
|
2585
|
+
* everything past the pipe buffer (64 KiB on macOS) whenever stdout is a pipe
|
|
2586
|
+
* rather than a TTY — so `celilo completion zsh > file` was whole while
|
|
2587
|
+
* `GENERATED=$(celilo completion zsh)` lost its tail mid-line, with exit 0 and
|
|
2588
|
+
* no diagnostic anywhere. install-zsh-completion.sh captured exactly that way
|
|
2589
|
+
* and installed a truncated, unparseable `_celilo`.
|
|
2590
|
+
*
|
|
2591
|
+
* Awaiting the stream's own write callback is the whole fix: it fires once the
|
|
2592
|
+
* bytes have been accepted downstream, so the following `process.exit()` can no
|
|
2593
|
+
* longer race the flush.
|
|
2594
|
+
*/
|
|
2595
|
+
async function writeStdout(text: string): Promise<void> {
|
|
2596
|
+
await new Promise<void>((resolve, reject) => {
|
|
2597
|
+
process.stdout.write(text, (err) => (err ? reject(err) : resolve()));
|
|
2598
|
+
});
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2581
2601
|
async function serverMode(): Promise<void> {
|
|
2582
2602
|
const readline = await import('node:readline');
|
|
2583
2603
|
|
|
@@ -2699,7 +2719,7 @@ export async function main(): Promise<void> {
|
|
|
2699
2719
|
if (result.success) {
|
|
2700
2720
|
// For completion mode, output raw text without formatting
|
|
2701
2721
|
if (process.argv.includes('--get-completions') || process.argv.includes('completion')) {
|
|
2702
|
-
|
|
2722
|
+
await writeStdout(`${result.message}\n`);
|
|
2703
2723
|
process.exit(0);
|
|
2704
2724
|
}
|
|
2705
2725
|
|
|
@@ -2722,7 +2742,7 @@ export async function main(): Promise<void> {
|
|
|
2722
2742
|
// `line.startsWith('<module-id> ')` over `celilo module list` match
|
|
2723
2743
|
// nothing — read in celilo#695 as a missing module rather than as a
|
|
2724
2744
|
// parse failure, at the cost of a full e2e run.
|
|
2725
|
-
|
|
2745
|
+
await writeStdout(`${result.message}\n`);
|
|
2726
2746
|
process.exit(0);
|
|
2727
2747
|
}
|
|
2728
2748
|
|
|
@@ -37,7 +37,7 @@ const MODULE_ID = 'restore-migration-test';
|
|
|
37
37
|
|
|
38
38
|
/** The manifest the module row carries: one on_restore hook, the fixture. */
|
|
39
39
|
const MODULE_MANIFEST = {
|
|
40
|
-
celilo_contract: '
|
|
40
|
+
celilo_contract: '2.0',
|
|
41
41
|
id: MODULE_ID,
|
|
42
42
|
name: 'Restore Migration Test',
|
|
43
43
|
version: '0.0.1',
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
// `process.stdout.write()` followed by `process.exit()` drops everything past
|
|
4
|
+
// the 64 KiB OS pipe buffer when stdout is a pipe. The zsh completion script is
|
|
5
|
+
// the CLI's largest stdout payload (~70 KB), so it is the cheapest witness.
|
|
6
|
+
//
|
|
7
|
+
// The capture has to go through a real shell: a Bun.spawn pipe is not an OS
|
|
8
|
+
// pipe and never truncates, so a test written that way passes with the defect
|
|
9
|
+
// present. `$(...)` is how install-zsh-completion.sh reads the generator, and
|
|
10
|
+
// it read 65536 bytes of a 70092-byte script — exit 0, no diagnostic, and a
|
|
11
|
+
// `_celilo` cut off mid-line installed into the operator's fpath.
|
|
12
|
+
//
|
|
13
|
+
// The invariant is that a capture and a redirect of the same command agree to
|
|
14
|
+
// within the trailing newlines `$(...)` strips. Both are measured in the same
|
|
15
|
+
// shell, so nothing here depends on the generator's exact size.
|
|
16
|
+
describe('CLI stdout survives an OS pipe', () => {
|
|
17
|
+
test('completion zsh is captured whole, not truncated at 64 KiB', async () => {
|
|
18
|
+
const cli = `bun run ${import.meta.dir}/index.ts completion zsh`;
|
|
19
|
+
const proc = Bun.spawn(
|
|
20
|
+
[
|
|
21
|
+
'bash',
|
|
22
|
+
'-c',
|
|
23
|
+
`f=$(mktemp); ${cli} > "$f"; out=$(${cli}); printf %s "$out" | wc -c; wc -c < "$f"; rm -f "$f"`,
|
|
24
|
+
],
|
|
25
|
+
{ stdout: 'pipe', stderr: 'ignore' },
|
|
26
|
+
);
|
|
27
|
+
const [captured, redirected] = (await new Response(proc.stdout).text())
|
|
28
|
+
.trim()
|
|
29
|
+
.split('\n')
|
|
30
|
+
.map((n) => Number(n.trim()));
|
|
31
|
+
|
|
32
|
+
expect(redirected).toBeGreaterThan(65536); // below the buffer this proves nothing
|
|
33
|
+
expect(redirected - captured).toBeLessThanOrEqual(2); // only stripped newlines
|
|
34
|
+
}, 60_000);
|
|
35
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `private_routes` — the internal ingress's own declared rows (slice 2 of
|
|
3
|
+
* providers-converge-declared-state, task 2.1).
|
|
4
|
+
*
|
|
5
|
+
* Same shape as `web_routes`, separate table on purpose. caddy derives the
|
|
6
|
+
* hostnames it SERVES from every row of `web_routes`, so a private route stored
|
|
7
|
+
* there would be picked up and served by the PUBLIC ingress — the exact leak
|
|
8
|
+
* caddy-internal exists to prevent (celilo#846). The isolation is therefore a
|
|
9
|
+
* property worth asserting rather than assuming: a table boundary is only as
|
|
10
|
+
* good as the queries that respect it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
14
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { eq } from 'drizzle-orm';
|
|
18
|
+
import { setupTestDatabaseAt } from '../test-utils/database';
|
|
19
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
20
|
+
import type { DbClient } from './client';
|
|
21
|
+
import { modules, privateRoutes, webRoutes } from './schema';
|
|
22
|
+
|
|
23
|
+
describe('private_routes', () => {
|
|
24
|
+
let dir: string;
|
|
25
|
+
let db: DbClient;
|
|
26
|
+
|
|
27
|
+
beforeEach(async () => {
|
|
28
|
+
dir = mkdtempSync(join(tmpdir(), 'private-routes-'));
|
|
29
|
+
const dbPath = join(dir, 'celilo.db');
|
|
30
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
31
|
+
db = await setupTestDatabaseAt(dbPath);
|
|
32
|
+
|
|
33
|
+
for (const id of ['wireguard-manager', 'hello-foo']) {
|
|
34
|
+
db.insert(modules)
|
|
35
|
+
.values({
|
|
36
|
+
id,
|
|
37
|
+
name: id,
|
|
38
|
+
version: '1.0.0',
|
|
39
|
+
manifestData: {},
|
|
40
|
+
sourcePath: join(dir, 'modules', id),
|
|
41
|
+
state: 'VERIFIED',
|
|
42
|
+
})
|
|
43
|
+
.onConflictDoNothing()
|
|
44
|
+
.run();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
db.$client.close();
|
|
50
|
+
resetTestDbPath();
|
|
51
|
+
try {
|
|
52
|
+
rmSync(dir, { recursive: true, force: true });
|
|
53
|
+
} catch {
|
|
54
|
+
/* ignore */
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function insertPrivate(overrides: Record<string, unknown> = {}) {
|
|
59
|
+
db.insert(privateRoutes)
|
|
60
|
+
.values({
|
|
61
|
+
slug: 'vpn-manager',
|
|
62
|
+
moduleId: 'wireguard-manager',
|
|
63
|
+
type: 'reverse_proxy',
|
|
64
|
+
path: '/',
|
|
65
|
+
hostname: 'vpn-manager.celilo.computer',
|
|
66
|
+
targetHost: '10.0.20.15',
|
|
67
|
+
targetPort: 3000,
|
|
68
|
+
websocket: false,
|
|
69
|
+
...overrides,
|
|
70
|
+
})
|
|
71
|
+
.run();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test('carries web_routes columns, so one renderer shape serves both', () => {
|
|
75
|
+
insertPrivate();
|
|
76
|
+
const row = db.select().from(privateRoutes).get();
|
|
77
|
+
|
|
78
|
+
// Named individually rather than compared as a set: the point is that a
|
|
79
|
+
// renderer written against WebRoute reads this row unchanged.
|
|
80
|
+
expect(row?.slug).toBe('vpn-manager');
|
|
81
|
+
expect(row?.moduleId).toBe('wireguard-manager');
|
|
82
|
+
expect(row?.type).toBe('reverse_proxy');
|
|
83
|
+
expect(row?.path).toBe('/');
|
|
84
|
+
expect(row?.hostname).toBe('vpn-manager.celilo.computer');
|
|
85
|
+
expect(row?.targetHost).toBe('10.0.20.15');
|
|
86
|
+
expect(row?.targetPort).toBe(3000);
|
|
87
|
+
expect(row?.websocket).toBe(false);
|
|
88
|
+
expect(row?.contentHash ?? null).toBeNull();
|
|
89
|
+
expect(row?.createdAt).toBeInstanceOf(Date);
|
|
90
|
+
expect(row?.updatedAt).toBeInstanceOf(Date);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a private route is unreachable from the public provider query (celilo#846)', () => {
|
|
94
|
+
insertPrivate();
|
|
95
|
+
|
|
96
|
+
// This is the query the public provider runs — buildRouteOps.getAllRoutes,
|
|
97
|
+
// which caddy's renderer turns into served hostnames. It must not see a
|
|
98
|
+
// private row, or the private hostname gets a public site block and a
|
|
99
|
+
// public certificate.
|
|
100
|
+
const publicRows = db.select().from(webRoutes).all();
|
|
101
|
+
expect(publicRows).toEqual([]);
|
|
102
|
+
|
|
103
|
+
// And the converse, so the assertion cannot pass merely because nothing
|
|
104
|
+
// was written anywhere.
|
|
105
|
+
expect(db.select().from(privateRoutes).all()).toHaveLength(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a public and a private route may share a hostname and path without colliding', () => {
|
|
109
|
+
// Separate tables means separate unique indexes. Same name served by both
|
|
110
|
+
// ingresses is a legitimate split-horizon arrangement, and a shared table
|
|
111
|
+
// would have rejected the second row.
|
|
112
|
+
insertPrivate({ hostname: 'shared.celilo.computer' });
|
|
113
|
+
db.insert(webRoutes)
|
|
114
|
+
.values({
|
|
115
|
+
slug: 'shared',
|
|
116
|
+
moduleId: 'hello-foo',
|
|
117
|
+
type: 'reverse_proxy',
|
|
118
|
+
path: '/',
|
|
119
|
+
hostname: 'shared.celilo.computer',
|
|
120
|
+
targetHost: '10.0.10.9',
|
|
121
|
+
targetPort: 8080,
|
|
122
|
+
websocket: false,
|
|
123
|
+
})
|
|
124
|
+
.run();
|
|
125
|
+
|
|
126
|
+
expect(db.select().from(privateRoutes).all()).toHaveLength(1);
|
|
127
|
+
expect(db.select().from(webRoutes).all()).toHaveLength(1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('uniqueness is scoped to (hostname, path), as web_routes is', () => {
|
|
131
|
+
insertPrivate();
|
|
132
|
+
expect(() => insertPrivate({ moduleId: 'hello-foo', slug: 'other' })).toThrow();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('removing the consumer removes its routes (consumer cascade)', () => {
|
|
136
|
+
insertPrivate();
|
|
137
|
+
db.insert(privateRoutes)
|
|
138
|
+
.values({
|
|
139
|
+
slug: 'other',
|
|
140
|
+
moduleId: 'hello-foo',
|
|
141
|
+
type: 'reverse_proxy',
|
|
142
|
+
path: '/other',
|
|
143
|
+
hostname: 'vpn-manager.celilo.computer',
|
|
144
|
+
targetHost: '10.0.20.16',
|
|
145
|
+
targetPort: 3000,
|
|
146
|
+
websocket: false,
|
|
147
|
+
})
|
|
148
|
+
.run();
|
|
149
|
+
|
|
150
|
+
db.delete(modules).where(eq(modules.id, 'wireguard-manager')).run();
|
|
151
|
+
|
|
152
|
+
// The departing consumer's row goes; the other consumer's stays. A private
|
|
153
|
+
// route outliving its module would render a site block pointing at a
|
|
154
|
+
// backend nothing owns.
|
|
155
|
+
const left = db.select().from(privateRoutes).all();
|
|
156
|
+
expect(left).toHaveLength(1);
|
|
157
|
+
expect(left[0]?.moduleId).toBe('hello-foo');
|
|
158
|
+
});
|
|
159
|
+
});
|
package/src/db/schema.ts
CHANGED
|
@@ -816,6 +816,55 @@ export const webRoutes = sqliteTable(
|
|
|
816
816
|
}),
|
|
817
817
|
);
|
|
818
818
|
|
|
819
|
+
/**
|
|
820
|
+
* Private-route registry — the desired-state store for the `private_web`
|
|
821
|
+
* capability (openspec/changes/providers-converge-declared-state, slice 2).
|
|
822
|
+
*
|
|
823
|
+
* `web_routes`' columns, its own table. The shape is shared so ONE renderer
|
|
824
|
+
* shape serves both ingresses and the plumbing around them is the same
|
|
825
|
+
* (design D1); the table is separate because the PUBLIC provider derives the
|
|
826
|
+
* hostnames it serves from every row of `web_routes`, so a private route
|
|
827
|
+
* stored there would be picked up and served publicly — the exact leak
|
|
828
|
+
* caddy-internal exists to prevent (celilo#846). Two tables is what makes that
|
|
829
|
+
* impossible rather than merely intended.
|
|
830
|
+
*
|
|
831
|
+
* This replaces caddy-internal's `routes` module-config key, which held the
|
|
832
|
+
* same rows as module business inside one module's config. No data migration
|
|
833
|
+
* (design D9): the table starts empty, and when caddy-internal deploys,
|
|
834
|
+
* provider arrival re-registers its consumers, whose hooks write their routes
|
|
835
|
+
* into it inside that same deploy.
|
|
836
|
+
* @owner capability:private_web — one row is one route on the internal ingress
|
|
837
|
+
*/
|
|
838
|
+
export const privateRoutes = sqliteTable(
|
|
839
|
+
'private_routes',
|
|
840
|
+
{
|
|
841
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
842
|
+
slug: text('slug').notNull(),
|
|
843
|
+
moduleId: text('module_id')
|
|
844
|
+
.notNull()
|
|
845
|
+
.references(() => modules.id, { onDelete: 'cascade' }),
|
|
846
|
+
type: text('type').$type<'static' | 'reverse_proxy'>().notNull(),
|
|
847
|
+
path: text('path').notNull(),
|
|
848
|
+
/** FQDN this route serves under, in the INTERNAL DNS view only. */
|
|
849
|
+
hostname: text('hostname').notNull(),
|
|
850
|
+
targetHost: text('target_host'),
|
|
851
|
+
targetPort: integer('target_port'),
|
|
852
|
+
websocket: integer('websocket', { mode: 'boolean' }).notNull().default(false),
|
|
853
|
+
contentHash: text('content_hash'),
|
|
854
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
855
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
856
|
+
},
|
|
857
|
+
(table) => ({
|
|
858
|
+
// Scoped to hostname exactly as web_routes is, and scoped to THIS table:
|
|
859
|
+
// the same hostname and path may exist on both ingresses, which is a
|
|
860
|
+
// legitimate split-horizon arrangement.
|
|
861
|
+
hostnamePathUnique: uniqueIndex('private_routes_hostname_path_idx').on(
|
|
862
|
+
table.hostname,
|
|
863
|
+
table.path,
|
|
864
|
+
),
|
|
865
|
+
}),
|
|
866
|
+
);
|
|
867
|
+
|
|
819
868
|
/**
|
|
820
869
|
* Port-forward registry — the desired-state store for the `firewall` capability
|
|
821
870
|
* (openspec/changes/unified-management-no-ssh/proposal.md). One row per exposed forward. `exposeService`
|