@celilo/cli 0.4.0-alpha.1 → 0.4.1

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 (58) 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/restore.ts +5 -0
  13. package/src/cli/commands/storage-add-s3.ts +91 -46
  14. package/src/cli/completion.ts +2 -1
  15. package/src/cli/index.ts +11 -0
  16. package/src/db/client.ts +4 -0
  17. package/src/db/schema.ts +9 -1
  18. package/src/hooks/capability-loader.test.ts +31 -1
  19. package/src/hooks/capability-loader.ts +65 -16
  20. package/src/manifest/contracts/v1.ts +12 -0
  21. package/src/manifest/schema.ts +13 -1
  22. package/src/manifest/template-validator.ts +1 -0
  23. package/src/module/import.ts +10 -5
  24. package/src/module/packaging/build.test.ts +75 -0
  25. package/src/module/packaging/build.ts +9 -20
  26. package/src/module/packaging/package-rules.ts +44 -0
  27. package/src/secrets/generators.test.ts +14 -1
  28. package/src/secrets/generators.ts +63 -1
  29. package/src/services/aspect-approvals.test.ts +30 -10
  30. package/src/services/aspect-approvals.ts +61 -31
  31. package/src/services/aspect-runner.test.ts +161 -8
  32. package/src/services/aspect-runner.ts +156 -34
  33. package/src/services/backup-create.ts +11 -2
  34. package/src/services/bus-ensure-flow.test.ts +19 -1
  35. package/src/services/bus-interview.ts +56 -0
  36. package/src/services/bus-secret-flow.test.ts +19 -1
  37. package/src/services/celilo-events.test.ts +122 -0
  38. package/src/services/celilo-events.ts +144 -0
  39. package/src/services/celilo-mgmt-hooks.test.ts +30 -3
  40. package/src/services/config-interview.ts +38 -19
  41. package/src/services/deploy-planner.test.ts +66 -0
  42. package/src/services/deploy-planner.ts +16 -2
  43. package/src/services/deploy-preflight.ts +18 -1
  44. package/src/services/deployed-systems.ts +30 -1
  45. package/src/services/dns-provider-backfill.test.ts +150 -0
  46. package/src/services/dns-provider-backfill.ts +72 -2
  47. package/src/services/e2e-guard.test.ts +38 -0
  48. package/src/services/e2e-guard.ts +43 -0
  49. package/src/services/module-deploy.ts +12 -26
  50. package/src/services/responder-probe.test.ts +87 -0
  51. package/src/services/responder-probe.ts +29 -0
  52. package/src/services/restore-from-file.test.ts +46 -0
  53. package/src/services/restore-from-file.ts +106 -9
  54. package/src/services/storage-providers/s3.test.ts +101 -0
  55. package/src/templates/generator.test.ts +77 -0
  56. package/src/templates/generator.ts +69 -2
  57. package/src/variables/context.ts +34 -0
  58. package/src/variables/lxc-nameserver.test.ts +86 -0
@@ -13,6 +13,10 @@ import {
13
13
  emitUninstallCompleted,
14
14
  emitUninstallFailed,
15
15
  emitUninstallStarted,
16
+ emitWebRoutesChanged,
17
+ emitWebRoutesChangedAndWait,
18
+ routesChangedHighWater,
19
+ waitForRouteReconcile,
16
20
  } from './celilo-events';
17
21
 
18
22
  describe('celilo lifecycle events', () => {
@@ -165,4 +169,122 @@ describe('celilo lifecycle events', () => {
165
169
  process.env.EVENT_BUS_DB = '/proc/no/such/place/events.db';
166
170
  expect(() => emitDeployStarted({ module: 'x', startedAt: 0 })).not.toThrow();
167
171
  });
172
+
173
+ describe('deploy-waits for web-route reconcile (ISS-0035)', () => {
174
+ function subscribeReconciler(): void {
175
+ const bus = openBus({ dbPath, events: defineEvents({}) });
176
+ bus.subscribe({
177
+ name: 'caddy.reconcile-web-routes',
178
+ pattern: 'public_web.routes_changed',
179
+ handler: 'unused',
180
+ });
181
+ bus.close();
182
+ }
183
+
184
+ /** Stamp a fresh dispatcher heartbeat so health() reports a live dispatcher. */
185
+ function markDispatcherAlive(): void {
186
+ const bus = openBus({ dbPath, events: defineEvents({}) });
187
+ bus.db.run(
188
+ `INSERT OR REPLACE INTO dispatcher_heartbeat
189
+ (dispatcher_id, last_heartbeat, started_at, pid, version)
190
+ VALUES ('test', ?, ?, 1, '0.1.0')`,
191
+ [Date.now(), Date.now()],
192
+ );
193
+ bus.close();
194
+ }
195
+
196
+ function settleDelivery(how: 'succeed' | 'abandon'): void {
197
+ const bus = openBus({ dbPath, events: defineEvents({}) });
198
+ const sub = bus.getSubscriberByName('caddy.reconcile-web-routes');
199
+ const event = bus.recentEvents({ type: 'public_web.routes_changed', limit: 1 })[0];
200
+ if (!sub || !event) {
201
+ throw new Error('expected a subscriber and an emitted event');
202
+ }
203
+ if (how === 'succeed') {
204
+ bus.markSucceeded({ eventId: event.id, subscriberId: sub.id });
205
+ } else {
206
+ bus.markFailed({ eventId: event.id, subscriberId: sub.id }, 'boom', { abandoned: true });
207
+ }
208
+ bus.close();
209
+ }
210
+
211
+ it('routesChangedHighWater is 0 with no events, then the latest id', () => {
212
+ expect(routesChangedHighWater()).toBe(0);
213
+ emitWebRoutesChanged('celilo-website');
214
+ expect(routesChangedHighWater()).toBeGreaterThan(0);
215
+ });
216
+
217
+ it('returns immediately when the deploy changed no routes', async () => {
218
+ const since = routesChangedHighWater();
219
+ const result = await waitForRouteReconcile(since, { timeoutMs: 1000 });
220
+ expect(result).toEqual({ events: 0, succeeded: 0, failed: 0, timedOut: false });
221
+ });
222
+
223
+ it('skips the wait (no time-out) when no dispatcher is running', async () => {
224
+ subscribeReconciler();
225
+ const since = routesChangedHighWater();
226
+ emitWebRoutesChanged('celilo-website');
227
+ // No heartbeat stamped → health() is no_dispatcher.
228
+ const result = await waitForRouteReconcile(since, { timeoutMs: 5000, pollMs: 50 });
229
+ expect(result).toEqual({ events: 1, succeeded: 0, failed: 0, timedOut: false });
230
+ });
231
+
232
+ it('settles immediately when no provider subscribes (zero deliveries)', async () => {
233
+ markDispatcherAlive();
234
+ const since = routesChangedHighWater();
235
+ emitWebRoutesChanged('celilo-website');
236
+ const result = await waitForRouteReconcile(since, { timeoutMs: 1000, pollMs: 50 });
237
+ expect(result).toEqual({ events: 1, succeeded: 0, failed: 0, timedOut: false });
238
+ });
239
+
240
+ it('times out while the provider delivery is still pending', async () => {
241
+ markDispatcherAlive();
242
+ subscribeReconciler();
243
+ const since = routesChangedHighWater();
244
+ emitWebRoutesChanged('celilo-website');
245
+ const result = await waitForRouteReconcile(since, { timeoutMs: 300, pollMs: 50 });
246
+ expect(result.events).toBe(1);
247
+ expect(result.timedOut).toBe(true);
248
+ });
249
+
250
+ it('completes once the provider delivery succeeds', async () => {
251
+ markDispatcherAlive();
252
+ subscribeReconciler();
253
+ const since = routesChangedHighWater();
254
+ emitWebRoutesChanged('celilo-website');
255
+ settleDelivery('succeed');
256
+
257
+ const result = await waitForRouteReconcile(since, { timeoutMs: 1000, pollMs: 50 });
258
+ expect(result).toEqual({ events: 1, succeeded: 1, failed: 0, timedOut: false });
259
+ });
260
+
261
+ it('reports a failed delivery without timing out', async () => {
262
+ markDispatcherAlive();
263
+ subscribeReconciler();
264
+ const since = routesChangedHighWater();
265
+ emitWebRoutesChanged('celilo-website');
266
+ settleDelivery('abandon');
267
+
268
+ const result = await waitForRouteReconcile(since, { timeoutMs: 1000, pollMs: 50 });
269
+ expect(result.events).toBe(1);
270
+ expect(result.failed).toBe(1);
271
+ expect(result.timedOut).toBe(false);
272
+ });
273
+
274
+ it('emitWebRoutesChangedAndWait emits then waits for the reconcile to settle', async () => {
275
+ markDispatcherAlive();
276
+ subscribeReconciler();
277
+
278
+ // Settle the delivery concurrently, mid-wait, the way the dispatcher would.
279
+ const pending = emitWebRoutesChangedAndWait('celilo-website', {
280
+ timeoutMs: 2000,
281
+ pollMs: 50,
282
+ });
283
+ await new Promise((r) => setTimeout(r, 120));
284
+ settleDelivery('succeed');
285
+
286
+ const result = await pending;
287
+ expect(result).toEqual({ events: 1, succeeded: 1, failed: 0, timedOut: false });
288
+ });
289
+ });
168
290
  });
@@ -176,6 +176,150 @@ export function emitSystemDestroyed(payload: SystemDestroyedPayload): void {
176
176
  emitBest(`system.destroyed.${payload.module}`, payload);
177
177
  }
178
178
 
179
+ /**
180
+ * Emit `public_web.routes_changed` (ISS-0035). A coarse "the route table
181
+ * changed, re-read it" signal: a consumer registered or unregistered a route,
182
+ * so the public_web PROVIDER (caddy) should reconcile its running config from
183
+ * web_routes. The provider subscribes via a manifest `subscriptions:` entry.
184
+ * Best-effort — a failed emit never breaks register_route; the route is already
185
+ * persisted in web_routes and the provider's next deploy reconciles it anyway.
186
+ */
187
+ export function emitWebRoutesChanged(triggeredBy: string): void {
188
+ emitBest('public_web.routes_changed', { triggeredBy });
189
+ }
190
+
191
+ const ROUTES_CHANGED_TYPE = 'public_web.routes_changed';
192
+
193
+ /**
194
+ * Emit `public_web.routes_changed` and block until the provider's reconcile
195
+ * delivery settles (ISS-0035). This is what `register_route` / `unregister_routes`
196
+ * await so they return only once the route is actually live — restoring the
197
+ * synchronous guarantee the old SSH path gave, provider-agnostically. A
198
+ * consuming module's `health_check` runs right after it registers its route, so
199
+ * without this wait it races the ~1s async reconcile and sees the placeholder.
200
+ *
201
+ * Captures the high-water id BEFORE emitting so the wait targets exactly the
202
+ * event this call produced. Degrades safely: no dispatcher → returns without
203
+ * waiting; no provider subscribed → no deliveries → returns immediately.
204
+ */
205
+ export async function emitWebRoutesChangedAndWait(
206
+ triggeredBy: string,
207
+ opts?: { timeoutMs?: number; pollMs?: number },
208
+ ): Promise<RouteReconcileWaitResult> {
209
+ const since = routesChangedHighWater();
210
+ emitWebRoutesChanged(triggeredBy);
211
+ return waitForRouteReconcile(since, opts);
212
+ }
213
+
214
+ /**
215
+ * Outcome of {@link waitForRouteReconcile}. `events` is how many
216
+ * `public_web.routes_changed` events the deploy emitted; `succeeded`/`failed`
217
+ * count their settled deliveries (one per subscribing provider). `timedOut`
218
+ * is true when the deadline hit with deliveries still pending/running.
219
+ */
220
+ export interface RouteReconcileWaitResult {
221
+ events: number;
222
+ succeeded: number;
223
+ failed: number;
224
+ timedOut: boolean;
225
+ }
226
+
227
+ /**
228
+ * Highest `public_web.routes_changed` event id at this instant, or 0 if none
229
+ * exist yet. A deploy captures this before it runs so {@link waitForRouteReconcile}
230
+ * can tell which route-change events the deploy itself produced.
231
+ */
232
+ export function routesChangedHighWater(): number {
233
+ let bus: ReturnType<typeof openBus> | undefined;
234
+ try {
235
+ bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
236
+ return bus.recentEvents({ type: ROUTES_CHANGED_TYPE, limit: 1 })[0]?.id ?? 0;
237
+ } catch (err) {
238
+ const msg = err instanceof Error ? err.message : String(err);
239
+ console.warn(`[celilo] failed to read routes-changed high-water: ${msg}`);
240
+ return 0;
241
+ } finally {
242
+ bus?.close();
243
+ }
244
+ }
245
+
246
+ function sleep(ms: number): Promise<void> {
247
+ return new Promise((resolve) => setTimeout(resolve, ms));
248
+ }
249
+
250
+ /**
251
+ * Block until every `public_web.routes_changed` event emitted after
252
+ * `sinceEventId` has had all its deliveries settle (succeeded / failed /
253
+ * abandoned), or until `timeoutMs` elapses. This is what makes
254
+ * `module deploy` return only once the public_web PROVIDER (caddy) has
255
+ * reconciled its running config from web_routes — closing the race where
256
+ * deploy returned while the route was still the placeholder (ISS-0035).
257
+ *
258
+ * When the deploy registered no routes, or no provider subscribes to the
259
+ * event, there are zero deliveries and this returns immediately. With no
260
+ * dispatcher running, nothing will ever deliver the reconcile, so the wait is
261
+ * skipped rather than burning the whole deadline (the deploy-time dispatcher
262
+ * gate, ISS-0042, is the upstream guard). The wait is advisory: it never turns
263
+ * a bus error into a deploy failure. The caller inspects the result and
264
+ * decides what to surface.
265
+ */
266
+ export async function waitForRouteReconcile(
267
+ sinceEventId: number,
268
+ opts: { timeoutMs?: number; pollMs?: number } = {},
269
+ ): Promise<RouteReconcileWaitResult> {
270
+ const timeoutMs = opts.timeoutMs ?? 90_000;
271
+ const pollMs = opts.pollMs ?? 250;
272
+ const deadline = Date.now() + timeoutMs;
273
+
274
+ let bus: ReturnType<typeof openBus> | undefined;
275
+ try {
276
+ bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
277
+ const b = bus;
278
+
279
+ // The deploy's emits already happened, so the set of route-change events
280
+ // is fixed; only their deliveries transition as the dispatcher works.
281
+ const events = b
282
+ .recentEvents({ type: ROUTES_CHANGED_TYPE, limit: 100 })
283
+ .filter((e) => e.id > sinceEventId);
284
+
285
+ if (events.length === 0) {
286
+ return { events: 0, succeeded: 0, failed: 0, timedOut: false };
287
+ }
288
+
289
+ if (b.health().status === 'no_dispatcher') {
290
+ console.warn(
291
+ `[celilo] route-reconcile: no dispatcher running — not waiting; ${events.length} change event(s) persisted, the provider will reconcile on its next run`,
292
+ );
293
+ return { events: events.length, succeeded: 0, failed: 0, timedOut: false };
294
+ }
295
+
296
+ while (true) {
297
+ const deliveries = events.flatMap((e) => b.deliveriesForEvent(e.id));
298
+ const settled = (status: string) => deliveries.filter((d) => d.status === status).length;
299
+ const pending = deliveries.filter(
300
+ (d) => d.status === 'pending' || d.status === 'running',
301
+ ).length;
302
+
303
+ if (pending === 0 || Date.now() >= deadline) {
304
+ return {
305
+ events: events.length,
306
+ succeeded: settled('succeeded'),
307
+ failed: settled('failed') + settled('abandoned'),
308
+ timedOut: pending > 0,
309
+ };
310
+ }
311
+
312
+ await sleep(pollMs);
313
+ }
314
+ } catch (err) {
315
+ const msg = err instanceof Error ? err.message : String(err);
316
+ console.warn(`[celilo] route-reconcile wait errored: ${msg}`);
317
+ return { events: 0, succeeded: 0, failed: 0, timedOut: false };
318
+ } finally {
319
+ bus?.close();
320
+ }
321
+ }
322
+
179
323
  /**
180
324
  * Open the bus, emit, close. Errors are caught and logged so a
181
325
  * misbehaving bus never wedges the caller. The empty-registry mode
@@ -13,6 +13,7 @@
13
13
  * and (eventually) a fresh-box e2e.
14
14
  */
15
15
 
16
+ import { Database } from 'bun:sqlite';
16
17
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
17
18
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
19
  import { tmpdir } from 'node:os';
@@ -103,7 +104,14 @@ describe('celilo-mgmt on_backup', () => {
103
104
  );
104
105
 
105
106
  dbPath = join(dir, 'celilo.db');
106
- writeFileSync(dbPath, 'fake-sqlite-bytes-for-test');
107
+ // A REAL (minimal) SQLite DB. on_backup snapshots it via bun:sqlite
108
+ // serialize(), which requires a valid database file — a plain byte blob
109
+ // makes the serializer read a garbage page count and OOM. One table with a
110
+ // row is enough to exercise the snapshot path.
111
+ const seed = new Database(dbPath);
112
+ seed.run('CREATE TABLE backup_probe (id INTEGER PRIMARY KEY, v TEXT)');
113
+ seed.run("INSERT INTO backup_probe (v) VALUES ('hello')");
114
+ seed.close();
107
115
  process.env.CELILO_DB_PATH = dbPath;
108
116
 
109
117
  keyPath = join(dir, 'master.key');
@@ -139,11 +147,30 @@ describe('celilo-mgmt on_backup', () => {
139
147
  ).toBe(true);
140
148
  expect(existsSync(join(backupDir, 'cross_module_state', 'index.json'))).toBe(true);
141
149
 
142
- expect(result.schema_version).toBe('1.0');
150
+ expect(result.schema_version).toBe('1.1');
143
151
  expect(result.artifact_count).toBeGreaterThan(0);
144
152
  expect(result.size_bytes).toBeGreaterThan(0);
145
153
  });
146
154
 
155
+ it('captures module SOURCE (excluding generated/) into module_src/', async () => {
156
+ // stateDir = dirname(db_path) = dir; on_backup reads dir/modules/<id>.
157
+ const modSrc = join(dir, 'modules', 'caddy');
158
+ mkdirSync(join(modSrc, 'scripts'), { recursive: true });
159
+ mkdirSync(join(modSrc, 'generated', 'terraform'), { recursive: true });
160
+ writeFileSync(join(modSrc, 'manifest.yml'), 'id: caddy');
161
+ writeFileSync(join(modSrc, 'scripts', 'hook.ts'), '// hook');
162
+ writeFileSync(join(modSrc, 'generated', 'terraform', 'main.tf'), 'resource {}');
163
+
164
+ const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
165
+ await hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }));
166
+
167
+ // Source files captured...
168
+ expect(existsSync(join(backupDir, 'module_src', 'caddy', 'manifest.yml'))).toBe(true);
169
+ expect(existsSync(join(backupDir, 'module_src', 'caddy', 'scripts', 'hook.ts'))).toBe(true);
170
+ // ...but generated/ excluded (TF state travels via cross_module_state).
171
+ expect(existsSync(join(backupDir, 'module_src', 'caddy', 'generated'))).toBe(false);
172
+ });
173
+
147
174
  it('machine-pool.json is valid JSON (array)', async () => {
148
175
  const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
149
176
  await hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }));
@@ -160,7 +187,7 @@ describe('celilo-mgmt on_backup', () => {
160
187
 
161
188
  expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
162
189
  expect(existsSync(join(backupDir, 'cross_module_state'))).toBe(false);
163
- expect(result.schema_version).toBe('1.0');
190
+ expect(result.schema_version).toBe('1.1');
164
191
  });
165
192
 
166
193
  it('proceeds (with a warning) when master.key is missing on disk', async () => {
@@ -4,7 +4,7 @@ import type { DbClient } from '../db/client';
4
4
  import { moduleConfigs, modules, secrets } from '../db/schema';
5
5
  import type { Ensure } from '../manifest/schema';
6
6
  import { decryptSecret, encryptSecret } from '../secrets/encryption';
7
- import { deriveSecret, generateSecret } from '../secrets/generators';
7
+ import { deriveSecret, generateGpgPrivateKey, generateSecret } from '../secrets/generators';
8
8
  import { getOrCreateMasterKey } from '../secrets/master-key';
9
9
  import type { Machine } from '../types/infrastructure';
10
10
  import {
@@ -15,7 +15,7 @@ import {
15
15
  type EnsureRequiredPayload,
16
16
  type SecretAck,
17
17
  type SecretRequiredPayload,
18
- busInterview,
18
+ busInterviewGuarded,
19
19
  } from './bus-interview';
20
20
  import { parseStoredConfigValue, upsertModuleConfig } from './module-config';
21
21
  import { getSecretMetadata } from './secret-schema-loader';
@@ -75,7 +75,7 @@ interface SecretDeclareLike {
75
75
  key_pattern_message?: string;
76
76
  value_pattern?: string;
77
77
  value_pattern_message?: string;
78
- generate?: { method: string; length: number; encoding: string };
78
+ generate?: { method: string; length: number; encoding: string; identity?: string };
79
79
  }
80
80
 
81
81
  /**
@@ -106,7 +106,13 @@ function coerceSecretDeclare(entry: unknown): SecretDeclareLike | null {
106
106
  typeof g.length === 'number' &&
107
107
  typeof g.encoding === 'string'
108
108
  ) {
109
- out.generate = { method: g.method, length: g.length, encoding: g.encoding };
109
+ const gen: { method: string; length: number; encoding: string; identity?: string } = {
110
+ method: g.method as string,
111
+ length: g.length as number,
112
+ encoding: g.encoding as string,
113
+ };
114
+ if (typeof g.identity === 'string') gen.identity = g.identity;
115
+ out.generate = gen;
110
116
  }
111
117
  }
112
118
  return out;
@@ -123,7 +129,11 @@ export async function findMissingSecrets(
123
129
  for (const raw of manifest.secrets.declares) {
124
130
  const secret = coerceSecretDeclare(raw);
125
131
  if (!secret) continue;
126
- if (!secret.required) continue;
132
+ // Process a secret if it's required OR has a `generate` directive — an
133
+ // auto-generated secret (e.g. a GPG signing key) is needed for the module
134
+ // to function and must never be silently skipped just because it isn't
135
+ // marked required. Optional, non-generated secrets are still skipped.
136
+ if (!secret.required && !secret.generate) continue;
127
137
 
128
138
  const existing = db
129
139
  .select()
@@ -171,6 +181,7 @@ export interface MissingVariable {
171
181
  method: string;
172
182
  length: number;
173
183
  encoding: string;
184
+ identity?: string;
174
185
  };
175
186
  /** For `type: string-map` only — labels shown in the add-loop prompt. */
176
187
  key_label?: string;
@@ -340,7 +351,7 @@ export async function interviewForMissingConfig(
340
351
  required: true,
341
352
  description: followUpPrompt,
342
353
  };
343
- const followUpReply = await busInterview<ConfigReply>(
354
+ const followUpReply = await busInterviewGuarded<ConfigReply>(
344
355
  EVENT_TYPES.configRequired(moduleId, followUpKey),
345
356
  followUpPayload,
346
357
  );
@@ -405,7 +416,7 @@ export async function interviewForMissingConfig(
405
416
  description: variable.description,
406
417
  options: variable.options,
407
418
  };
408
- const reply = await busInterview<ConfigReply>(
419
+ const reply = await busInterviewGuarded<ConfigReply>(
409
420
  EVENT_TYPES.configRequired(moduleId, variable.name),
410
421
  payload,
411
422
  );
@@ -440,7 +451,7 @@ export async function interviewForMissingConfig(
440
451
  required: true,
441
452
  description: followUpPrompt,
442
453
  };
443
- const followUpReply = await busInterview<ConfigReply>(
454
+ const followUpReply = await busInterviewGuarded<ConfigReply>(
444
455
  EVENT_TYPES.configRequired(moduleId, followUpKey),
445
456
  followUpPayload,
446
457
  );
@@ -466,7 +477,7 @@ export async function interviewForMissingConfig(
466
477
  required: true,
467
478
  description: variable.description,
468
479
  };
469
- const reply = await busInterview<ConfigReply>(
480
+ const reply = await busInterviewGuarded<ConfigReply>(
470
481
  EVENT_TYPES.configRequired(moduleId, variable.name),
471
482
  payload,
472
483
  );
@@ -649,14 +660,19 @@ export async function interviewForMissingSecrets(
649
660
 
650
661
  log.message(`Derived ${variable.name} from ${metadata.deriveFrom}`);
651
662
  } else if (source === 'generated') {
652
- // Auto-generate without prompting
653
- // Manifest generate field takes priority over schema metadata
654
- const format = variable.generate?.encoding || metadata?.format || 'base64';
655
- const length = variable.generate?.length || metadata?.length || 32;
656
-
657
- value = generateSecret({ format, length });
658
-
659
- log.message(`Auto-generated ${format} secret: ${variable.name}`);
663
+ // Auto-generate without prompting. method: gpg mints a real signing
664
+ // key (celilo owns it as infrastructure); otherwise random bytes.
665
+ if (variable.generate?.method === 'gpg') {
666
+ const identity = variable.generate.identity || moduleId;
667
+ value = generateGpgPrivateKey(identity);
668
+ log.message(`Auto-generated GPG signing key for ${variable.name} (${identity})`);
669
+ } else {
670
+ // Manifest generate field takes priority over schema metadata
671
+ const format = variable.generate?.encoding || metadata?.format || 'base64';
672
+ const length = variable.generate?.length || metadata?.length || 32;
673
+ value = generateSecret({ format, length });
674
+ log.message(`Auto-generated ${format} secret: ${variable.name}`);
675
+ }
660
676
  } else if (
661
677
  source === 'user_provided' ||
662
678
  source === 'user_password' ||
@@ -694,7 +710,10 @@ export async function interviewForMissingSecrets(
694
710
  value_pattern: variable.value_pattern,
695
711
  value_pattern_message: variable.value_pattern_message,
696
712
  };
697
- await busInterview<SecretAck>(EVENT_TYPES.secretRequired(moduleId, variable.name), payload);
713
+ await busInterviewGuarded<SecretAck>(
714
+ EVENT_TYPES.secretRequired(moduleId, variable.name),
715
+ payload,
716
+ );
698
717
  log.success(`Saved ${variable.name}`);
699
718
  configured.push(`${variable.name} (secret)`);
700
719
  // Responder already wrote to the encrypted store; skip the
@@ -1071,7 +1090,7 @@ export async function interviewForEnsureInputs(
1071
1090
  objectKey: i.objectKey,
1072
1091
  })),
1073
1092
  };
1074
- const reply = await busInterview<EnsureReply>(
1093
+ const reply = await busInterviewGuarded<EnsureReply>(
1075
1094
  EVENT_TYPES.ensureRequired(providerModuleId, ensure.id),
1076
1095
  payload,
1077
1096
  );
@@ -0,0 +1,66 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { existsSync } from 'node:fs';
3
+ import { rm } from 'node:fs/promises';
4
+ import { type DbClient, createDbClient } from '../db/client';
5
+ import { moduleConfigs, modules } from '../db/schema';
6
+ import { extractTargetHost } from './deploy-planner';
7
+
8
+ const TEST_DB_PATH = './test-deploy-planner.db';
9
+
10
+ /**
11
+ * Regression for the malformed SSH target bug (ISS-0019): extractTargetHost
12
+ * read module_configs.valueJson RAW. valueJson is JSON-ENCODED — a string is
13
+ * stored as `"10.0.20.13/24"` (with quotes) — so splitting on '/' kept the
14
+ * leading quote, producing `root@"10.0.20.13` and a 180s SSH-wait timeout
15
+ * against a container that was actually up. It must parse valueJson.
16
+ */
17
+ describe('extractTargetHost', () => {
18
+ let db: DbClient;
19
+
20
+ beforeEach(() => {
21
+ db = createDbClient({ path: TEST_DB_PATH });
22
+ db.insert(modules)
23
+ .values({
24
+ id: 'apt-repo',
25
+ name: 'apt-repo',
26
+ version: '1.0.0',
27
+ manifestData: {},
28
+ sourcePath: '/tmp/apt-repo',
29
+ })
30
+ .run();
31
+ });
32
+
33
+ afterEach(async () => {
34
+ db.$client.close();
35
+ for (const suffix of ['', '-shm', '-wal']) {
36
+ const p = `${TEST_DB_PATH}${suffix}`;
37
+ if (existsSync(p)) await rm(p);
38
+ }
39
+ });
40
+
41
+ function setConfig(key: string, value: string): void {
42
+ db.insert(moduleConfigs)
43
+ .values({ moduleId: 'apt-repo', key, value, valueJson: JSON.stringify(value) })
44
+ .run();
45
+ }
46
+
47
+ test('strips the CIDR from a JSON-encoded target_ip without keeping the quote', async () => {
48
+ setConfig('hostname', 'apt.celilo.computer');
49
+ setConfig('target_ip', '10.0.20.13/24');
50
+
51
+ const host = await extractTargetHost('apt-repo', db);
52
+ expect(host.ip).toBe('10.0.20.13'); // not `"10.0.20.13`
53
+ expect(host.ip).not.toContain('"');
54
+ expect(host.hostname).toBe('apt.celilo.computer');
55
+ expect(host.user).toBe('root');
56
+ });
57
+
58
+ test('honors ansible_user and falls back through ip.primary', async () => {
59
+ setConfig('ip.primary', '203.0.113.7');
60
+ setConfig('ansible_user', 'peba');
61
+
62
+ const host = await extractTargetHost('apt-repo', db);
63
+ expect(host.ip).toBe('203.0.113.7');
64
+ expect(host.user).toBe('peba');
65
+ });
66
+ });
@@ -113,10 +113,24 @@ export async function extractTargetHost(
113
113
  .where(eq(moduleConfigs.moduleId, moduleId))
114
114
  .all();
115
115
 
116
- // Build config map
116
+ // Build config map. valueJson is JSON-ENCODED — a string value is stored as
117
+ // `"10.0.20.13/24"` (with quotes), so using it raw and then splitting on '/'
118
+ // kept the leading quote (`"10.0.20.13`), producing the malformed SSH target
119
+ // `root@"10.0.20.13`. Parse it (the pattern used everywhere else); fall back
120
+ // to the plain `value` column.
117
121
  const configMap = new Map<string, string>();
118
122
  for (const config of configs) {
119
- const value = config.valueJson || config.value;
123
+ let value: string | undefined;
124
+ if (config.valueJson != null) {
125
+ try {
126
+ const parsed: unknown = JSON.parse(config.valueJson);
127
+ value = typeof parsed === 'string' ? parsed : String(parsed);
128
+ } catch {
129
+ value = config.value ?? undefined;
130
+ }
131
+ } else {
132
+ value = config.value ?? undefined;
133
+ }
120
134
  if (value) {
121
135
  configMap.set(config.key, value);
122
136
  }
@@ -28,6 +28,7 @@ import { getDb } from '../db/client';
28
28
  import { capabilities, moduleConfigs, modules, secrets } from '../db/schema';
29
29
  import type { ModuleManifest } from '../manifest/schema';
30
30
  import { buildResolutionContext } from '../variables/context';
31
+ import { E2E_CONFLICT_FIX, runningE2eContainers } from './e2e-guard';
31
32
 
32
33
  export interface PreflightResult {
33
34
  success: boolean;
@@ -43,7 +44,8 @@ export interface PreflightError {
43
44
  | 'capability-version-mismatch'
44
45
  | 'unresolved-template'
45
46
  | 'no-infrastructure'
46
- | 'missing-secret';
47
+ | 'missing-secret'
48
+ | 'e2e-conflict';
47
49
  message: string;
48
50
  variable?: string;
49
51
  suggestion?: string;
@@ -86,6 +88,19 @@ export async function runPreflight(
86
88
 
87
89
  const manifest = module.manifestData as ModuleManifest;
88
90
 
91
+ // 1b. Environment: live deploys and the e2e simulator are mutually
92
+ // exclusive. The deploy refuses this same condition; surfacing it
93
+ // here means `--preflight` answers "can I deploy right now?" honestly
94
+ // instead of a false green that dies seconds into the real deploy.
95
+ const e2eContainers = runningE2eContainers();
96
+ if (e2eContainers.length > 0) {
97
+ errors.push({
98
+ category: 'e2e-conflict',
99
+ message: `e2e test containers are running (${e2eContainers.length}) — live and e2e environments are mutually exclusive`,
100
+ suggestion: E2E_CONFLICT_FIX,
101
+ });
102
+ }
103
+
89
104
  // 2. Required capabilities have providers, and at least one
90
105
  // provider's version is compatible. Multi-provider scenarios
91
106
  // (e.g., zone-scoped dns_registrar with separate internal +
@@ -311,5 +326,7 @@ function errorIcon(category: PreflightError['category']): string {
311
326
  return '▢';
312
327
  case 'missing-secret':
313
328
  return '🔑';
329
+ case 'e2e-conflict':
330
+ return '🐳';
314
331
  }
315
332
  }
@@ -1,5 +1,5 @@
1
1
  import type { DeployedSystem } from '@celilo/capabilities';
2
- import { and, eq } from 'drizzle-orm';
2
+ import { and, eq, inArray } from 'drizzle-orm';
3
3
  import type { DbClient } from '../db/client';
4
4
  import {
5
5
  type NetworkZone,
@@ -52,6 +52,35 @@ export function getModuleSystems(moduleId: string, db: DbClient): DeployedSystem
52
52
  return rows.map(rowToSystem).sort((a, b) => a.name.localeCompare(b.name));
53
53
  }
54
54
 
55
+ /**
56
+ * All container_service systems (Proxmox LXCs, droplets, …) whose zone is in
57
+ * `zones`, across every module — the LXC complement to machine-pool's
58
+ * `getSystemsByZone`. Used by the aspect fan-out to reach LXCs, not just
59
+ * machine-pool boxes (ISS-0028). Deduped by hostname (one LXC = one module = one
60
+ * row, but defensive). Ordered by hostname for determinism.
61
+ */
62
+ export function getContainerSystemsInZones(zones: string[], db: DbClient): DeployedSystem[] {
63
+ if (zones.length === 0) return [];
64
+ const rows = db
65
+ .select()
66
+ .from(moduleSystems)
67
+ .where(
68
+ and(
69
+ inArray(moduleSystems.zone, zones as NetworkZone[]),
70
+ eq(moduleSystems.infraType, 'container_service'),
71
+ ),
72
+ )
73
+ .all();
74
+ const seen = new Set<string>();
75
+ const out: DeployedSystem[] = [];
76
+ for (const row of rows) {
77
+ if (seen.has(row.hostname)) continue;
78
+ seen.add(row.hostname);
79
+ out.push(rowToSystem(row));
80
+ }
81
+ return out.sort((a, b) => a.hostname.localeCompare(b.hostname));
82
+ }
83
+
55
84
  /** Input to {@link upsertDeployedSystem} — the realized facts about one host. */
56
85
  export interface DeployedSystemInput {
57
86
  /** Stable handle from requires.systems[].name — the per-system key. */