@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.
- 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
|
@@ -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
|
-
|
|
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');
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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. */
|