@celilo/cli 0.4.0-alpha.1 → 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.
Files changed (55) hide show
  1. package/drizzle/0008_aspect_consent.sql +1 -0
  2. package/drizzle/meta/_journal.json +7 -0
  3. package/package.json +5 -6
  4. package/src/cli/command-registry.ts +38 -0
  5. package/src/cli/commands/backup-pull.test.ts +48 -0
  6. package/src/cli/commands/backup-pull.ts +116 -0
  7. package/src/cli/commands/events.test.ts +108 -0
  8. package/src/cli/commands/events.ts +243 -0
  9. package/src/cli/commands/module-generate.ts +5 -4
  10. package/src/cli/commands/module-import-aspect.test.ts +116 -0
  11. package/src/cli/commands/module-import.ts +12 -1
  12. package/src/cli/commands/storage-add-s3.ts +91 -46
  13. package/src/cli/completion.ts +2 -1
  14. package/src/cli/index.ts +11 -0
  15. package/src/db/client.ts +4 -0
  16. package/src/db/schema.ts +9 -1
  17. package/src/hooks/capability-loader.test.ts +31 -1
  18. package/src/hooks/capability-loader.ts +65 -16
  19. package/src/manifest/contracts/v1.ts +12 -0
  20. package/src/manifest/schema.ts +13 -1
  21. package/src/manifest/template-validator.ts +1 -0
  22. package/src/module/packaging/build.test.ts +75 -0
  23. package/src/module/packaging/build.ts +9 -20
  24. package/src/module/packaging/package-rules.ts +44 -0
  25. package/src/secrets/generators.test.ts +14 -1
  26. package/src/secrets/generators.ts +63 -1
  27. package/src/services/aspect-approvals.test.ts +30 -10
  28. package/src/services/aspect-approvals.ts +61 -31
  29. package/src/services/aspect-runner.test.ts +161 -8
  30. package/src/services/aspect-runner.ts +156 -34
  31. package/src/services/backup-create.ts +11 -2
  32. package/src/services/bus-ensure-flow.test.ts +19 -1
  33. package/src/services/bus-interview.ts +56 -0
  34. package/src/services/bus-secret-flow.test.ts +19 -1
  35. package/src/services/celilo-events.test.ts +122 -0
  36. package/src/services/celilo-events.ts +144 -0
  37. package/src/services/celilo-mgmt-hooks.test.ts +9 -1
  38. package/src/services/config-interview.ts +38 -19
  39. package/src/services/deploy-planner.test.ts +66 -0
  40. package/src/services/deploy-planner.ts +16 -2
  41. package/src/services/deploy-preflight.ts +18 -1
  42. package/src/services/deployed-systems.ts +30 -1
  43. package/src/services/dns-provider-backfill.test.ts +150 -0
  44. package/src/services/dns-provider-backfill.ts +72 -2
  45. package/src/services/e2e-guard.test.ts +38 -0
  46. package/src/services/e2e-guard.ts +43 -0
  47. package/src/services/module-deploy.ts +12 -26
  48. package/src/services/responder-probe.test.ts +87 -0
  49. package/src/services/responder-probe.ts +29 -0
  50. package/src/services/restore-from-file.ts +16 -6
  51. package/src/services/storage-providers/s3.test.ts +101 -0
  52. package/src/templates/generator.test.ts +77 -0
  53. package/src/templates/generator.ts +69 -2
  54. package/src/variables/context.ts +34 -0
  55. 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
- _flags: Record<string, boolean | string> = {},
18
+ flags: Record<string, boolean | string> = {},
19
19
  ): Promise<CommandResult> {
20
20
  try {
21
21
  celiloIntro('Add S3 Backup Storage');
22
22
 
23
- const name = await promptText({
24
- message: 'Human-readable name:',
25
- placeholder: 'e.g., Backblaze B2 Backups',
26
- validate: validateRequired('Storage name'),
27
- });
28
-
29
- const bucket = await promptText({
30
- message: 'Bucket name:',
31
- placeholder: 'e.g., homelab-backups',
32
- validate: validateRequired('Bucket name'),
33
- });
34
-
35
- const region = await promptText({
36
- message: 'Region:',
37
- defaultValue: 'us-east-1',
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 endpoint = await promptText({
43
- message: 'Endpoint URL:',
44
- defaultValue: 'https://s3.amazonaws.com',
45
- placeholder: 'https://s3.amazonaws.com',
46
- validate: (value) => {
47
- const err = validateRequired('Endpoint URL')(value);
48
- if (err) return err;
49
- if (value && !value.startsWith('https://') && !value.startsWith('http://')) {
50
- return 'Endpoint must start with https:// or http://';
51
- }
52
- return undefined;
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 = await promptText({
57
- message: 'Access Key ID:',
58
- validate: validateRequired('Access Key ID'),
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 = await promptPassword({
62
- message: 'Secret Access Key:',
63
- validate: validateRequired('Secret Access Key'),
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
- const makeDefault = await promptConfirm({
94
- message: 'Set as default backup destination?',
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(
@@ -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 --accept-aspects was used in a context with no USER. */
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 { DnsInternalCapability, HookLogger, RouteOps } from '@celilo/capabilities';
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 firewallProviders = db
236
- .select()
237
- .from(capabilities)
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,
@@ -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
  /**