@celilo/cli 0.4.0-alpha.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.
- package/drizzle/0008_aspect_consent.sql +1 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -6
- package/src/cli/command-registry.ts +38 -0
- package/src/cli/commands/backup-pull.test.ts +48 -0
- package/src/cli/commands/backup-pull.ts +116 -0
- package/src/cli/commands/events.test.ts +108 -0
- package/src/cli/commands/events.ts +243 -0
- package/src/cli/commands/module-generate.ts +5 -4
- package/src/cli/commands/module-import-aspect.test.ts +116 -0
- package/src/cli/commands/module-import.ts +12 -1
- package/src/cli/commands/storage-add-s3.ts +91 -46
- package/src/cli/completion.ts +2 -1
- package/src/cli/index.ts +11 -0
- package/src/db/client.ts +4 -0
- package/src/db/schema.ts +9 -1
- package/src/hooks/capability-loader.test.ts +31 -1
- package/src/hooks/capability-loader.ts +65 -16
- package/src/manifest/contracts/v1.ts +12 -0
- package/src/manifest/schema.ts +13 -1
- package/src/manifest/template-validator.ts +1 -0
- package/src/module/packaging/build.test.ts +75 -0
- package/src/module/packaging/build.ts +9 -20
- package/src/module/packaging/package-rules.ts +44 -0
- package/src/secrets/generators.test.ts +14 -1
- package/src/secrets/generators.ts +63 -1
- package/src/services/aspect-approvals.test.ts +30 -10
- package/src/services/aspect-approvals.ts +61 -31
- package/src/services/aspect-runner.test.ts +161 -8
- package/src/services/aspect-runner.ts +156 -34
- package/src/services/backup-create.ts +11 -2
- package/src/services/bus-ensure-flow.test.ts +19 -1
- package/src/services/bus-interview.ts +56 -0
- package/src/services/bus-secret-flow.test.ts +19 -1
- package/src/services/celilo-events.test.ts +122 -0
- package/src/services/celilo-events.ts +144 -0
- package/src/services/celilo-mgmt-hooks.test.ts +9 -1
- package/src/services/config-interview.ts +38 -19
- package/src/services/deploy-planner.test.ts +66 -0
- package/src/services/deploy-planner.ts +16 -2
- package/src/services/deploy-preflight.ts +18 -1
- package/src/services/deployed-systems.ts +30 -1
- package/src/services/dns-provider-backfill.test.ts +150 -0
- package/src/services/dns-provider-backfill.ts +72 -2
- package/src/services/e2e-guard.test.ts +38 -0
- package/src/services/e2e-guard.ts +43 -0
- package/src/services/module-deploy.ts +12 -26
- package/src/services/responder-probe.test.ts +87 -0
- package/src/services/responder-probe.ts +29 -0
- package/src/services/restore-from-file.ts +16 -6
- package/src/services/storage-providers/s3.test.ts +101 -0
- package/src/templates/generator.test.ts +77 -0
- package/src/templates/generator.ts +69 -2
- package/src/variables/context.ts +34 -0
- package/src/variables/lxc-nameserver.test.ts +86 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aspect-approval flow on import (ISS-0045): in a non-TTY context, importing a
|
|
3
|
+
* module that declares a base_module_aspect must fail fast with guidance rather
|
|
4
|
+
* than hang on the approval prompt; --accept-aspects approves non-interactively.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
8
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { closeDb, getDb } from '../../db/client';
|
|
12
|
+
import { runMigrations } from '../../db/migrate';
|
|
13
|
+
import { modules } from '../../db/schema';
|
|
14
|
+
import type { BaseModuleAspect } from '../../manifest/schema';
|
|
15
|
+
import { handleAspectApprovalAfterImport } from './module-import';
|
|
16
|
+
|
|
17
|
+
const aspect: BaseModuleAspect = {
|
|
18
|
+
ansible_role: 'dns-client-config',
|
|
19
|
+
applicable_zones: ['dmz', 'app', 'secure'],
|
|
20
|
+
triggers: ['on_install'],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function insertAspectModule(id: string, version: string): void {
|
|
24
|
+
getDb()
|
|
25
|
+
.insert(modules)
|
|
26
|
+
.values({
|
|
27
|
+
id,
|
|
28
|
+
name: id,
|
|
29
|
+
version,
|
|
30
|
+
manifestData: {
|
|
31
|
+
id,
|
|
32
|
+
name: id,
|
|
33
|
+
version,
|
|
34
|
+
celilo_contract: '1.0',
|
|
35
|
+
base_module_aspect: aspect,
|
|
36
|
+
},
|
|
37
|
+
sourcePath: `/tmp/${id}`,
|
|
38
|
+
})
|
|
39
|
+
.run();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Use defineProperty (not direct assignment): another suite may have redefined
|
|
43
|
+
// process.stdin.isTTY as a non-writable property, which makes `= false` throw.
|
|
44
|
+
function setTTY(value: boolean | undefined): void {
|
|
45
|
+
Object.defineProperty(process.stdin, 'isTTY', { value, configurable: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('handleAspectApprovalAfterImport (non-interactive)', () => {
|
|
49
|
+
let dir: string;
|
|
50
|
+
let originalIsTTY: boolean | undefined;
|
|
51
|
+
|
|
52
|
+
beforeEach(async () => {
|
|
53
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-import-aspect-test-'));
|
|
54
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
55
|
+
await runMigrations(process.env.CELILO_DB_PATH);
|
|
56
|
+
originalIsTTY = process.stdin.isTTY;
|
|
57
|
+
// Force non-interactive so the no-flag path can't block on a real prompt.
|
|
58
|
+
setTTY(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
setTTY(originalIsTTY);
|
|
63
|
+
closeDb();
|
|
64
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
65
|
+
try {
|
|
66
|
+
rmSync(dir, { recursive: true, force: true });
|
|
67
|
+
} catch {
|
|
68
|
+
/* ignore */
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('fails fast (no hang) when stdin is not a TTY and --accept-aspects is absent', async () => {
|
|
73
|
+
insertAspectModule('technitium', '1.0.0');
|
|
74
|
+
const result = await handleAspectApprovalAfterImport({
|
|
75
|
+
moduleId: 'technitium',
|
|
76
|
+
targetPath: '/tmp/technitium',
|
|
77
|
+
flags: {},
|
|
78
|
+
db: getDb(),
|
|
79
|
+
});
|
|
80
|
+
expect(result.approved).toBe(false);
|
|
81
|
+
if (result.approved) throw new Error('expected approval to be declined');
|
|
82
|
+
expect(result.error).toContain('--accept-aspects');
|
|
83
|
+
expect(result.error).toContain("isn't a TTY");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('approves non-interactively when --accept-aspects is passed', async () => {
|
|
87
|
+
insertAspectModule('technitium', '1.0.0');
|
|
88
|
+
const result = await handleAspectApprovalAfterImport({
|
|
89
|
+
moduleId: 'technitium',
|
|
90
|
+
targetPath: '/tmp/technitium',
|
|
91
|
+
flags: { 'accept-aspects': true },
|
|
92
|
+
db: getDb(),
|
|
93
|
+
});
|
|
94
|
+
expect(result.approved).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('is a no-op for a module without a base_module_aspect', async () => {
|
|
98
|
+
getDb()
|
|
99
|
+
.insert(modules)
|
|
100
|
+
.values({
|
|
101
|
+
id: 'caddy',
|
|
102
|
+
name: 'caddy',
|
|
103
|
+
version: '1.0.0',
|
|
104
|
+
manifestData: { id: 'caddy', name: 'caddy', version: '1.0.0', celilo_contract: '1.0' },
|
|
105
|
+
sourcePath: '/tmp/caddy',
|
|
106
|
+
})
|
|
107
|
+
.run();
|
|
108
|
+
const result = await handleAspectApprovalAfterImport({
|
|
109
|
+
moduleId: 'caddy',
|
|
110
|
+
targetPath: '/tmp/caddy',
|
|
111
|
+
flags: {},
|
|
112
|
+
db: getDb(),
|
|
113
|
+
});
|
|
114
|
+
expect(result.approved).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -71,7 +71,7 @@ Examples:
|
|
|
71
71
|
* `{ approved: false, error: string }` on decline or non-interactive
|
|
72
72
|
* mismatch. The caller is responsible for surfacing the error.
|
|
73
73
|
*/
|
|
74
|
-
async function handleAspectApprovalAfterImport(args: {
|
|
74
|
+
export async function handleAspectApprovalAfterImport(args: {
|
|
75
75
|
moduleId: string;
|
|
76
76
|
targetPath: string;
|
|
77
77
|
flags: Record<string, string | boolean>;
|
|
@@ -120,6 +120,17 @@ async function handleAspectApprovalAfterImport(args: {
|
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
// Non-interactive (e2e / CI / automation): there is no one to answer the
|
|
124
|
+
// approval prompt, so fail fast with guidance instead of hanging on stdin
|
|
125
|
+
// until an outer timeout kills us (ISS-0045). Mirrors the secret-interview /
|
|
126
|
+
// module-generate non-TTY behavior.
|
|
127
|
+
if (!process.stdin.isTTY) {
|
|
128
|
+
return {
|
|
129
|
+
approved: false,
|
|
130
|
+
error: `Module '${moduleId}' declares a base-module aspect (Ansible role '${aspect.ansible_role}', zones: ${aspect.applicable_zones.join(', ')}; triggers: ${aspect.triggers.join(', ')}). Approval can't be prompted because stdin isn't a TTY. Re-run with --accept-aspects to approve it non-interactively.`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
123
134
|
// Interactive prompt path. Display the scope clearly so the
|
|
124
135
|
// operator's consent is informed.
|
|
125
136
|
const scopeMsg = [
|
|
@@ -15,53 +15,92 @@ import { validateRequired } from '../validators';
|
|
|
15
15
|
|
|
16
16
|
export async function handleStorageAddS3(
|
|
17
17
|
_args: string[],
|
|
18
|
-
|
|
18
|
+
flags: Record<string, boolean | string> = {},
|
|
19
19
|
): Promise<CommandResult> {
|
|
20
20
|
try {
|
|
21
21
|
celiloIntro('Add S3 Backup Storage');
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
placeholder: 'us-east-1',
|
|
39
|
-
validate: validateRequired('Region'),
|
|
40
|
-
});
|
|
23
|
+
// Support non-interactive mode via flags (scriptable from docker-exec /
|
|
24
|
+
// automation). region + endpoint have sensible defaults; name, bucket, and
|
|
25
|
+
// the two credential flags are required for a fully non-interactive run.
|
|
26
|
+
const flagName = typeof flags.name === 'string' ? flags.name : undefined;
|
|
27
|
+
const flagBucket = typeof flags.bucket === 'string' ? flags.bucket : undefined;
|
|
28
|
+
const flagRegion = typeof flags.region === 'string' ? flags.region : undefined;
|
|
29
|
+
const flagEndpoint = typeof flags.endpoint === 'string' ? flags.endpoint : undefined;
|
|
30
|
+
const flagAccessKeyId =
|
|
31
|
+
typeof flags['access-key-id'] === 'string' ? flags['access-key-id'] : undefined;
|
|
32
|
+
const flagSecretAccessKey =
|
|
33
|
+
typeof flags['secret-access-key'] === 'string' ? flags['secret-access-key'] : undefined;
|
|
34
|
+
|
|
35
|
+
const nonInteractive = Boolean(
|
|
36
|
+
flagName && flagBucket && flagAccessKeyId && flagSecretAccessKey,
|
|
37
|
+
);
|
|
41
38
|
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
39
|
+
const name =
|
|
40
|
+
flagName ??
|
|
41
|
+
(await promptText({
|
|
42
|
+
message: 'Human-readable name:',
|
|
43
|
+
placeholder: 'e.g., Backblaze B2 Backups',
|
|
44
|
+
validate: validateRequired('Storage name'),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const bucket =
|
|
48
|
+
flagBucket ??
|
|
49
|
+
(await promptText({
|
|
50
|
+
message: 'Bucket name:',
|
|
51
|
+
placeholder: 'e.g., homelab-backups',
|
|
52
|
+
validate: validateRequired('Bucket name'),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const region =
|
|
56
|
+
flagRegion ??
|
|
57
|
+
(await promptText({
|
|
58
|
+
message: 'Region:',
|
|
59
|
+
defaultValue: 'us-east-1',
|
|
60
|
+
placeholder: 'us-east-1',
|
|
61
|
+
validate: validateRequired('Region'),
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
const endpoint =
|
|
65
|
+
flagEndpoint ??
|
|
66
|
+
(await promptText({
|
|
67
|
+
message: 'Endpoint URL:',
|
|
68
|
+
defaultValue: 'https://s3.amazonaws.com',
|
|
69
|
+
placeholder: 'https://s3.amazonaws.com',
|
|
70
|
+
validate: (value) => {
|
|
71
|
+
const err = validateRequired('Endpoint URL')(value);
|
|
72
|
+
if (err) return err;
|
|
73
|
+
if (value && !value.startsWith('https://') && !value.startsWith('http://')) {
|
|
74
|
+
return 'Endpoint must start with https:// or http://';
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
if (
|
|
81
|
+
flagEndpoint &&
|
|
82
|
+
!flagEndpoint.startsWith('https://') &&
|
|
83
|
+
!flagEndpoint.startsWith('http://')
|
|
84
|
+
) {
|
|
85
|
+
return {
|
|
86
|
+
success: false,
|
|
87
|
+
error: `Invalid --endpoint '${flagEndpoint}': must start with https:// or http://`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
55
90
|
|
|
56
|
-
const accessKeyId =
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
91
|
+
const accessKeyId =
|
|
92
|
+
flagAccessKeyId ??
|
|
93
|
+
(await promptText({
|
|
94
|
+
message: 'Access Key ID:',
|
|
95
|
+
validate: validateRequired('Access Key ID'),
|
|
96
|
+
}));
|
|
60
97
|
|
|
61
|
-
const secretAccessKey =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
98
|
+
const secretAccessKey =
|
|
99
|
+
flagSecretAccessKey ??
|
|
100
|
+
(await promptPassword({
|
|
101
|
+
message: 'Secret Access Key:',
|
|
102
|
+
validate: validateRequired('Secret Access Key'),
|
|
103
|
+
}));
|
|
65
104
|
|
|
66
105
|
const storage = await addBackupStorage({
|
|
67
106
|
name,
|
|
@@ -90,14 +129,20 @@ export async function handleStorageAddS3(
|
|
|
90
129
|
|
|
91
130
|
console.log(`✓ ${result.message}`);
|
|
92
131
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
initialValue: true,
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
if (makeDefault) {
|
|
132
|
+
if (nonInteractive) {
|
|
133
|
+
// Non-interactive: auto-set as default (matches storage-add-local).
|
|
99
134
|
setDefaultBackupStorage(storage.id);
|
|
100
135
|
console.log('✓ Set as default');
|
|
136
|
+
} else {
|
|
137
|
+
const makeDefault = await promptConfirm({
|
|
138
|
+
message: 'Set as default backup destination?',
|
|
139
|
+
initialValue: true,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (makeDefault) {
|
|
143
|
+
setDefaultBackupStorage(storage.id);
|
|
144
|
+
console.log('✓ Set as default');
|
|
145
|
+
}
|
|
101
146
|
}
|
|
102
147
|
|
|
103
148
|
celiloOutro(
|
package/src/cli/completion.ts
CHANGED
|
@@ -81,6 +81,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
81
81
|
'run',
|
|
82
82
|
'run-hook',
|
|
83
83
|
'emit',
|
|
84
|
+
'reply',
|
|
84
85
|
'ack',
|
|
85
86
|
'fail',
|
|
86
87
|
'repair',
|
|
@@ -399,7 +400,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
399
400
|
|
|
400
401
|
// Backup subcommands
|
|
401
402
|
if (command === 'backup' && currentIndex === 1) {
|
|
402
|
-
const subcommands = ['create', 'list', 'restore', 'delete', 'prune', 'name', 'import'];
|
|
403
|
+
const subcommands = ['create', 'list', 'restore', 'delete', 'prune', 'name', 'import', 'pull'];
|
|
403
404
|
return filterSuggestions(subcommands, args[1] || '');
|
|
404
405
|
}
|
|
405
406
|
|
package/src/cli/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
handleEventsListPending,
|
|
20
20
|
handleEventsListSubscribers,
|
|
21
21
|
handleEventsRepair,
|
|
22
|
+
handleEventsReply,
|
|
22
23
|
handleEventsRespond,
|
|
23
24
|
handleEventsRun,
|
|
24
25
|
handleEventsRunHook,
|
|
@@ -258,6 +259,7 @@ Subcommands:
|
|
|
258
259
|
drain [--concurrency N] Process pending deliveries once and return
|
|
259
260
|
run [--poll-ms N] Run the long-running dispatcher (foreground)
|
|
260
261
|
emit <type> [<payload>] Emit an event (operator/test path)
|
|
262
|
+
reply <event_id> <value> Answer one pending interview query by id (config/secret/ensure)
|
|
261
263
|
ack <event_id> Mark a running delivery succeeded
|
|
262
264
|
fail <event_id> --error MSG Mark a running delivery failed
|
|
263
265
|
repair Crash-recovery sweep without starting the dispatcher
|
|
@@ -279,6 +281,8 @@ Examples:
|
|
|
279
281
|
celilo events status # is anything stuck?
|
|
280
282
|
celilo events tail --type deploy.completed.lunacycle # filter by type
|
|
281
283
|
celilo events emit deploy.completed.lunacycle '{}' # operator-fired event
|
|
284
|
+
celilo events tail --type 'config.required.*' # see pending deploy questions
|
|
285
|
+
celilo events reply 42 '"lunacycle.net"' # answer query #42 (config)
|
|
282
286
|
`;
|
|
283
287
|
return { success: true, message: helpText.trim() };
|
|
284
288
|
}
|
|
@@ -1150,6 +1154,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1150
1154
|
return handleEventsRunHook(parsed.args);
|
|
1151
1155
|
case 'emit':
|
|
1152
1156
|
return handleEventsEmit(parsed.args, parsed.flags);
|
|
1157
|
+
case 'reply':
|
|
1158
|
+
return handleEventsReply(parsed.args, parsed.flags);
|
|
1153
1159
|
case 'ack':
|
|
1154
1160
|
return handleEventsAck(parsed.args, parsed.flags);
|
|
1155
1161
|
case 'fail':
|
|
@@ -1569,6 +1575,11 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1569
1575
|
return handleBackupImport(parsed.args, parsed.flags);
|
|
1570
1576
|
}
|
|
1571
1577
|
|
|
1578
|
+
if (parsed.subcommand === 'pull') {
|
|
1579
|
+
const { handleBackupPull } = await import('./commands/backup-pull');
|
|
1580
|
+
return handleBackupPull(parsed.args, parsed.flags);
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1572
1583
|
return {
|
|
1573
1584
|
success: false,
|
|
1574
1585
|
error: `Unknown backup subcommand: ${parsed.subcommand}\n\nRun "celilo backup --help" for usage`,
|
package/src/db/client.ts
CHANGED
|
@@ -109,6 +109,10 @@ function needsMigration(sqlite: Database): boolean {
|
|
|
109
109
|
updated_at integer DEFAULT (unixepoch()) NOT NULL,
|
|
110
110
|
PRIMARY KEY (module_id, name)
|
|
111
111
|
)`,
|
|
112
|
+
// Aspect consent decision (ISS-0027). Fresh DBs get this via migration
|
|
113
|
+
// 0008; existing installs get it here. Defaults to true so pre-existing
|
|
114
|
+
// rows (all approvals) keep running; false = a durable refusal.
|
|
115
|
+
'ALTER TABLE aspect_approvals ADD consented integer DEFAULT true NOT NULL',
|
|
112
116
|
];
|
|
113
117
|
|
|
114
118
|
for (const stmt of alterStatements) {
|
package/src/db/schema.ts
CHANGED
|
@@ -540,8 +540,16 @@ export const aspectApprovals = sqliteTable(
|
|
|
540
540
|
version: text('version').notNull(),
|
|
541
541
|
/** Hash of `applicable_zones` + `triggers` — see table comment. */
|
|
542
542
|
scopeHash: text('scope_hash').notNull(),
|
|
543
|
+
/**
|
|
544
|
+
* The operator's decision for this (module, version, scope): `true` =
|
|
545
|
+
* approved (run the aspect), `false` = explicitly refused (skip and do NOT
|
|
546
|
+
* re-prompt — ISS-0027). The ABSENCE of a row is the third state, "not yet
|
|
547
|
+
* decided" → interview. Defaults to true so pre-existing rows (all of which
|
|
548
|
+
* were approvals) keep running.
|
|
549
|
+
*/
|
|
550
|
+
consented: integer('consented', { mode: 'boolean' }).notNull().default(true),
|
|
543
551
|
approvedAt: integer('approved_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
544
|
-
/** Operator identifier (e.g., $USER at approval time). Null when
|
|
552
|
+
/** Operator identifier (e.g., $USER at approval/denial time). Null when consent was granted in a context with no USER. */
|
|
545
553
|
approver: text('approver'),
|
|
546
554
|
},
|
|
547
555
|
(table) => ({
|
|
@@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
|
6
6
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
-
import type { HookLogger } from '@celilo/capabilities';
|
|
9
|
+
import type { HookLogger, RouteReadView } from '@celilo/capabilities';
|
|
10
10
|
import type { DbClient } from '../db/client';
|
|
11
11
|
import { upsertModuleConfig } from '../services/module-config';
|
|
12
12
|
import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
|
|
@@ -96,4 +96,34 @@ describe('Capability Loader', () => {
|
|
|
96
96
|
expect(result).toHaveProperty('dns_registrar');
|
|
97
97
|
expect(result.dns_registrar).toBeTruthy();
|
|
98
98
|
});
|
|
99
|
+
|
|
100
|
+
test('injects a read-only web_routes view into the public_web provider own hooks (ISS-0035)', async () => {
|
|
101
|
+
const modulePath = join(tempDir, 'caddy');
|
|
102
|
+
mkdirSync(modulePath, { recursive: true });
|
|
103
|
+
db.$client.run(
|
|
104
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('caddy', 'Caddy', '1.0.0', '${modulePath}', '{}')`,
|
|
105
|
+
);
|
|
106
|
+
db.$client.run(
|
|
107
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('caddy', 'public_web', '1.0.0', '{}', unixepoch())`,
|
|
108
|
+
);
|
|
109
|
+
db.$client.run(
|
|
110
|
+
`INSERT INTO web_routes (slug, module_id, type, path, hostname, target_host, target_port, websocket) VALUES ('apt--root', 'apt-repo', 'reverse_proxy', '/', 'apt.example.com', '10.0.20.50', 8080, 0)`,
|
|
111
|
+
);
|
|
112
|
+
db.$client.run(
|
|
113
|
+
`INSERT INTO web_routes (slug, module_id, type, path, hostname, target_host, target_port, websocket) VALUES ('auth--root', 'authentik', 'reverse_proxy', '/', 'auth.example.com', '10.0.20.51', 9000, 0)`,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// caddy running its OWN hook gets a read-only view of the route registry.
|
|
117
|
+
const provider = await loadCapabilityFunctions('caddy', db, noopLogger);
|
|
118
|
+
expect(provider).toHaveProperty('web_routes');
|
|
119
|
+
const view = provider.web_routes as RouteReadView;
|
|
120
|
+
const all = view.getAllRoutes();
|
|
121
|
+
expect(all).toHaveLength(2);
|
|
122
|
+
expect(all.map((r) => r.hostname).sort()).toEqual(['apt.example.com', 'auth.example.com']);
|
|
123
|
+
expect(view.getRoutes('apt-repo').map((r) => r.hostname)).toEqual(['apt.example.com']);
|
|
124
|
+
|
|
125
|
+
// A consumer (not the provider) never sees the route table.
|
|
126
|
+
const consumer = await loadCapabilityFunctions('apt-repo', db, noopLogger);
|
|
127
|
+
expect(consumer).not.toHaveProperty('web_routes');
|
|
128
|
+
});
|
|
99
129
|
});
|
|
@@ -17,12 +17,18 @@ import {
|
|
|
17
17
|
isCompiledCapabilityFactory,
|
|
18
18
|
wrapWithLogging,
|
|
19
19
|
} from '@celilo/capabilities';
|
|
20
|
-
import type {
|
|
20
|
+
import type {
|
|
21
|
+
DnsInternalCapability,
|
|
22
|
+
HookLogger,
|
|
23
|
+
RouteOps,
|
|
24
|
+
RouteReadView,
|
|
25
|
+
} from '@celilo/capabilities';
|
|
21
26
|
import { and, eq } from 'drizzle-orm';
|
|
22
27
|
import type { DbClient } from '../db/client';
|
|
23
28
|
import { capabilities, modules, secrets, webRoutes } from '../db/schema';
|
|
24
29
|
import { decryptSecret } from '../secrets/encryption';
|
|
25
30
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
31
|
+
import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
|
|
26
32
|
import { getModuleSystems } from '../services/deployed-systems';
|
|
27
33
|
import { loadHookConfigMap } from './load-hook-config';
|
|
28
34
|
|
|
@@ -84,6 +90,29 @@ const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFactoryName:
|
|
|
84
90
|
* @param logger - Hook logger captured by the auto-logging wrapper
|
|
85
91
|
* @returns Map of capability name to function interface
|
|
86
92
|
*/
|
|
93
|
+
/**
|
|
94
|
+
* The firewall's internal NAT IP — split-horizon DNS records point here so
|
|
95
|
+
* internal-zone clients reach a service via the iptables DNAT rules rather than
|
|
96
|
+
* Caddy's unreachable DMZ container IP. Returns undefined when no firewall
|
|
97
|
+
* provider advertises a `nat_ip`. Shared by the live public_web registration
|
|
98
|
+
* and the deploy-time web-route DNS backfill (ISS-0029) so both write the same
|
|
99
|
+
* value.
|
|
100
|
+
*/
|
|
101
|
+
export async function resolveFirewallNatIp(db: DbClient): Promise<string | undefined> {
|
|
102
|
+
const firewallProviders = db
|
|
103
|
+
.select()
|
|
104
|
+
.from(capabilities)
|
|
105
|
+
.where(eq(capabilities.capabilityName, 'firewall'))
|
|
106
|
+
.all();
|
|
107
|
+
for (const fp of firewallProviders) {
|
|
108
|
+
const fpConfig = await loadModuleConfig(fp.moduleId, db);
|
|
109
|
+
if (typeof fpConfig.nat_ip === 'string' && fpConfig.nat_ip) {
|
|
110
|
+
return fpConfig.nat_ip;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
87
116
|
export async function loadCapabilityFunctions(
|
|
88
117
|
consumingModuleId: string,
|
|
89
118
|
db: DbClient,
|
|
@@ -232,21 +261,9 @@ export async function loadCapabilityFunctions(
|
|
|
232
261
|
|
|
233
262
|
// Find the firewall NAT IP so split-horizon records point to the iptables
|
|
234
263
|
// internal interface rather than Caddy's unreachable DMZ container IP.
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
.where(eq(capabilities.capabilityName, 'firewall'))
|
|
239
|
-
.all();
|
|
240
|
-
let firewallNatIp: string | undefined;
|
|
241
|
-
for (const fp of firewallProviders) {
|
|
242
|
-
const fpConfig = await loadModuleConfig(fp.moduleId, db);
|
|
243
|
-
if (typeof fpConfig.nat_ip === 'string' && fpConfig.nat_ip) {
|
|
244
|
-
firewallNatIp = fpConfig.nat_ip;
|
|
245
|
-
debugLog(
|
|
246
|
-
`public_web: using firewall natIp ${firewallNatIp} from ${fp.moduleId} for internal DNS`,
|
|
247
|
-
);
|
|
248
|
-
break;
|
|
249
|
-
}
|
|
264
|
+
const firewallNatIp = await resolveFirewallNatIp(db);
|
|
265
|
+
if (firewallNatIp) {
|
|
266
|
+
debugLog(`public_web: using firewall natIp ${firewallNatIp} for internal DNS`);
|
|
250
267
|
}
|
|
251
268
|
|
|
252
269
|
// Caddy's configured hostnames — public_web rejects routes for any
|
|
@@ -343,6 +360,24 @@ export async function loadCapabilityFunctions(
|
|
|
343
360
|
caddyModuleId: provider.moduleId,
|
|
344
361
|
dnsManagedDomains,
|
|
345
362
|
dnsRegistrarModuleId,
|
|
363
|
+
// ISS-0035: register_route/unregister_routes emit this coarse signal
|
|
364
|
+
// instead of SSHing caddy; the caddy provider's reconcile_routes
|
|
365
|
+
// subscription re-renders the Caddyfile from web_routes. We await the
|
|
366
|
+
// reconcile so register_route returns only once the route is live —
|
|
367
|
+
// the consuming module's health_check runs right after and would
|
|
368
|
+
// otherwise race the async reconcile.
|
|
369
|
+
onRoutesChanged: async () => {
|
|
370
|
+
const reconcile = await emitWebRoutesChangedAndWait(consumingModuleId);
|
|
371
|
+
if (reconcile.timedOut) {
|
|
372
|
+
logger.warn(
|
|
373
|
+
`public_web reconcile for ${consumingModuleId} did not finish within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} change event(s)); the route is persisted and the provider will reconcile on its next run`,
|
|
374
|
+
);
|
|
375
|
+
} else if (reconcile.failed > 0) {
|
|
376
|
+
logger.warn(
|
|
377
|
+
`public_web reconcile for ${consumingModuleId}: ${reconcile.failed} delivery(ies) failed; the route is persisted but the provider config may be stale`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
},
|
|
346
381
|
});
|
|
347
382
|
debugLog(`public_web: loaded via framework implementation for ${consumingModuleId}`);
|
|
348
383
|
} catch (error) {
|
|
@@ -350,6 +385,20 @@ export async function loadCapabilityFunctions(
|
|
|
350
385
|
`public_web: FAILED to load: ${error instanceof Error ? error.message : String(error)}`,
|
|
351
386
|
);
|
|
352
387
|
}
|
|
388
|
+
|
|
389
|
+
// ISS-0035: when the module running THIS hook is the public_web PROVIDER
|
|
390
|
+
// itself (caddy running its own hook, not a consumer), give it a read-only
|
|
391
|
+
// view of the route registry so it can reconcile its config from web_routes
|
|
392
|
+
// — symmetric with how consumers get the public_web capability. Consumers
|
|
393
|
+
// never see this; only the provider does.
|
|
394
|
+
if (consumingModuleId === provider.moduleId) {
|
|
395
|
+
const routeView: RouteReadView = {
|
|
396
|
+
getAllRoutes: () => routeOps.getAllRoutes(),
|
|
397
|
+
getRoutes: (m: string) => routeOps.getRoutes(m),
|
|
398
|
+
};
|
|
399
|
+
result.web_routes = routeView;
|
|
400
|
+
debugLog(`web_routes: read-only route view injected for provider ${consumingModuleId}`);
|
|
401
|
+
}
|
|
353
402
|
} else {
|
|
354
403
|
debugLog('public_web: not registered in DB, skipping');
|
|
355
404
|
}
|
|
@@ -166,6 +166,18 @@ export const V1_HOOKS: ContractHooks = {
|
|
|
166
166
|
},
|
|
167
167
|
outputs: {},
|
|
168
168
|
},
|
|
169
|
+
/**
|
|
170
|
+
* Reconcile the provider's running config from a celilo registry when a
|
|
171
|
+
* change event fires (ISS-0035). The caddy public_web provider declares this;
|
|
172
|
+
* the dispatcher invokes it on `public_web.routes_changed` so caddy re-renders
|
|
173
|
+
* its Caddyfile from web_routes (read via the injected web_routes view +
|
|
174
|
+
* config). No required inputs — the event is a coarse "re-read the table"
|
|
175
|
+
* signal. No structured outputs; throws on failure (no-surprises).
|
|
176
|
+
*/
|
|
177
|
+
reconcile_routes: {
|
|
178
|
+
inputs: {},
|
|
179
|
+
outputs: {},
|
|
180
|
+
},
|
|
169
181
|
/**
|
|
170
182
|
* Build-bus upstream publish hook. The executor passes the
|
|
171
183
|
* PublishEvent fields as env vars (CELILO_EVENT_PAYLOAD,
|
package/src/manifest/schema.ts
CHANGED
|
@@ -88,9 +88,13 @@ export const VariableImportSchema = z.object({
|
|
|
88
88
|
* When present, the secret is auto-generated during deployment instead of prompting the user
|
|
89
89
|
*/
|
|
90
90
|
export const SecretGenerateSchema = z.object({
|
|
91
|
-
method: z.enum(['random']).default('random'),
|
|
91
|
+
method: z.enum(['random', 'gpg']).default('random'),
|
|
92
92
|
length: z.number().int().positive().default(32),
|
|
93
93
|
encoding: z.enum(['base64', 'hex']).default('base64'),
|
|
94
|
+
// For method: 'gpg' — the user ID stamped on the generated signing key
|
|
95
|
+
// (e.g. "apt.celilo.computer"). The secret holds the base64'd ASCII-armored
|
|
96
|
+
// private key, minted once at the config-interview stage.
|
|
97
|
+
identity: z.string().optional(),
|
|
94
98
|
});
|
|
95
99
|
|
|
96
100
|
export const SecretDeclareSchema = z.object({
|
|
@@ -511,6 +515,14 @@ export const ModuleManifestSchema = z
|
|
|
511
515
|
* [[v2/INTERNAL_DNS_DHCP_AND_SPLIT_HORIZON.md]] D5.
|
|
512
516
|
*/
|
|
513
517
|
on_system_event: LifecycleHookSchema.optional(),
|
|
518
|
+
/**
|
|
519
|
+
* Reconcile the provider's running config from a celilo registry on a
|
|
520
|
+
* change event. The caddy `public_web` provider declares this to
|
|
521
|
+
* re-render its Caddyfile from web_routes when a consumer registers or
|
|
522
|
+
* unregisters a route (ISS-0035). See
|
|
523
|
+
* [[v2/PUBLIC_WEB_PROVIDER_RECONCILE.md]].
|
|
524
|
+
*/
|
|
525
|
+
reconcile_routes: LifecycleHookSchema.optional(),
|
|
514
526
|
/**
|
|
515
527
|
* Build-bus upstream publish hooks. Array (a module can react
|
|
516
528
|
* to multiple upstream packages with different actions). See
|
|
@@ -72,6 +72,7 @@ const AUTO_ALLOCATED_VARIABLES = new Set([
|
|
|
72
72
|
'vlan', // Auto-derived from zone configuration
|
|
73
73
|
'gateway', // Auto-derived from zone configuration
|
|
74
74
|
'target_node', // Can be auto-derived from system config
|
|
75
|
+
'lxc_nameserver', // Composed at generate time from dns_internal + dns.primary (v2/LXC_INTERNAL_DNS.md)
|
|
75
76
|
]);
|
|
76
77
|
|
|
77
78
|
/**
|