@celilo/cli 0.5.0-alpha.1 → 0.5.0-alpha.11
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/0009_dns_registrations.sql +13 -0
- package/drizzle/0010_dns_internal_records.sql +12 -0
- package/drizzle/0011_backups_name.sql +1 -0
- package/drizzle/meta/_journal.json +22 -1
- package/package.json +3 -3
- package/src/ansible/inventory.test.ts +10 -10
- package/src/ansible/validation.test.ts +25 -15
- package/src/api-clients/proxmox.test.ts +211 -1
- package/src/api-clients/proxmox.ts +399 -8
- package/src/cli/command-registry.ts +83 -6
- package/src/cli/commands/backup-delete.ts +10 -7
- package/src/cli/commands/backup-import.ts +11 -8
- package/src/cli/commands/backup-restore.ts +11 -8
- package/src/cli/commands/dns.ts +57 -0
- package/src/cli/commands/events.test.ts +4 -4
- package/src/cli/commands/events.ts +89 -24
- package/src/cli/commands/machine-add.ts +178 -163
- package/src/cli/commands/machine-remove.ts +10 -7
- package/src/cli/commands/module-config.test.ts +78 -0
- package/src/cli/commands/module-config.ts +18 -3
- package/src/cli/commands/module-import.ts +9 -5
- package/src/cli/commands/module-publish.ts +24 -0
- package/src/cli/commands/module-remove.ts +20 -9
- package/src/cli/commands/module-status.ts +15 -0
- package/src/cli/commands/module-upgrade.test.ts +37 -0
- package/src/cli/commands/module-upgrade.ts +26 -6
- package/src/cli/commands/proxmox-node-list.ts +101 -0
- package/src/cli/commands/proxmox-template-selection.ts +16 -15
- package/src/cli/commands/proxmox-vm-template-build.ts +171 -0
- package/src/cli/commands/publish/alpha.test.ts +26 -0
- package/src/cli/commands/publish/alpha.ts +23 -0
- package/src/cli/commands/publish/types.ts +7 -2
- package/src/cli/commands/publish/workspace.ts +11 -1
- package/src/cli/commands/restore.ts +29 -0
- package/src/cli/commands/service-add-digitalocean.ts +120 -109
- package/src/cli/commands/service-add-proxmox.ts +283 -209
- package/src/cli/commands/service-reconfigure.test.ts +115 -0
- package/src/cli/commands/service-reconfigure.ts +252 -129
- package/src/cli/commands/service-remove.ts +19 -13
- package/src/cli/commands/service-verify.ts +9 -10
- package/src/cli/commands/storage-add-local.ts +120 -107
- package/src/cli/commands/storage-add-s3.ts +145 -131
- package/src/cli/commands/storage-remove.ts +11 -8
- package/src/cli/commands/system-doctor.ts +135 -40
- package/src/cli/commands/system-init.ts +119 -128
- package/src/cli/commands/system-migrate.test.ts +40 -0
- package/src/cli/commands/system-migrate.ts +65 -0
- package/src/cli/completion.ts +23 -0
- package/src/cli/index.ts +91 -7
- package/src/cli/service-credential.ts +54 -0
- package/src/config/paths.test.ts +61 -48
- package/src/db/client.ts +15 -146
- package/src/db/migrate.ts +14 -6
- package/src/db/schema-introspection.ts +88 -0
- package/src/db/schema.ts +74 -0
- package/src/hooks/capability-loader-firewall.test.ts +3 -3
- package/src/hooks/capability-loader.ts +43 -2
- package/src/hooks/run-named-hook.ts +28 -2
- package/src/hooks/types.ts +2 -1
- package/src/infrastructure/property-extractor.test.ts +15 -0
- package/src/infrastructure/property-extractor.ts +12 -0
- package/src/manifest/contracts/v1.ts +16 -0
- package/src/manifest/schema.ts +17 -0
- package/src/manifest/validate.test.ts +53 -0
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-interview.ts +232 -0
- package/src/services/bus-secret-flow.test.ts +2 -2
- package/src/services/celilo-mgmt-hooks.test.ts +3 -2
- package/src/services/deploy-preflight.ts +25 -0
- package/src/services/deploy-validation.test.ts +54 -4
- package/src/services/deploy-validation.ts +27 -36
- package/src/services/dns-internal-records.test.ts +126 -0
- package/src/services/dns-internal-records.ts +119 -0
- package/src/services/dns-provider-backfill.test.ts +2 -2
- package/src/services/dns-provider-backfill.ts +14 -2
- package/src/services/dns-registrations.test.ts +120 -0
- package/src/services/dns-registrations.ts +108 -0
- package/src/services/events-daemon.test.ts +59 -0
- package/src/services/events-daemon.ts +191 -57
- package/src/services/fleet-checks.test.ts +508 -0
- package/src/services/fleet-checks.ts +678 -0
- package/src/services/module-build.test.ts +43 -38
- package/src/services/module-config.ts +12 -0
- package/src/services/module-deploy.ts +7 -6
- package/src/services/module-subscriptions.test.ts +88 -0
- package/src/services/module-subscriptions.ts +50 -1
- package/src/services/module-validator/bundled-deps.test.ts +55 -0
- package/src/services/module-validator/bundled-deps.ts +115 -0
- package/src/services/module-validator/capability-versions.test.ts +1 -1
- package/src/services/placement-reconcile.test.ts +86 -0
- package/src/services/placement-reconcile.ts +108 -0
- package/src/services/programmatic-responder.ts +34 -0
- package/src/services/terminal-responder.ts +113 -0
- package/src/templates/generator.test.ts +92 -12
- package/src/templates/generator.ts +165 -80
- package/src/test-utils/fixtures.test.ts +1 -1
- package/src/test-utils/integration-guard.ts +33 -0
- package/src/types/infrastructure.ts +6 -0
- package/src/variables/computed/computed-integration.test.ts +3 -3
- package/src/variables/computed/computed.test.ts +5 -5
- package/src/variables/declarative-derivation.test.ts +6 -6
|
@@ -23,6 +23,13 @@ export const EVENT_TYPES = {
|
|
|
23
23
|
secretRequired: (module: string, key: string) => `secret.required.${module}.${key}`,
|
|
24
24
|
ensureRequired: (provider: string, ensureId: string) => `ensure.required.${provider}.${ensureId}`,
|
|
25
25
|
aspectRequired: (module: string, role: string) => `aspect.required.${module}.${role}`,
|
|
26
|
+
/**
|
|
27
|
+
* Generic interview family (ISS-0127). Unlike the module-scoped families
|
|
28
|
+
* above, this carries a free-form `scope` (e.g. `service:proxmox-home-lab`)
|
|
29
|
+
* so non-deploy operator commands can ask questions over the same bus the
|
|
30
|
+
* deploy interview uses — making them headlessly drivable.
|
|
31
|
+
*/
|
|
32
|
+
interviewRequired: (scope: string, key: string) => `interview.required.${scope}.${key}`,
|
|
26
33
|
} as const;
|
|
27
34
|
|
|
28
35
|
/**
|
|
@@ -182,6 +189,58 @@ export interface EnsureReply {
|
|
|
182
189
|
acknowledged?: true;
|
|
183
190
|
}
|
|
184
191
|
|
|
192
|
+
/**
|
|
193
|
+
* The four shapes a generic interview question can take. The responder
|
|
194
|
+
* renders by `kind`: `text` is a free-text prompt (with type coercion +
|
|
195
|
+
* pattern validation), `confirm` is a yes/no, `select` is a single choice
|
|
196
|
+
* from `options`, `multiselect` is zero-or-more choices from `options`.
|
|
197
|
+
*/
|
|
198
|
+
export type InterviewKind = 'text' | 'confirm' | 'select' | 'multiselect';
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Payload for `interview.required.<scope>.<key>` (ISS-0127). The generic
|
|
202
|
+
* counterpart to `ConfigRequiredPayload`: it carries everything a responder
|
|
203
|
+
* needs to render any of the four `kind`s, plus a `scope`/`key` pair that
|
|
204
|
+
* gives the question a stable identity a headless responder can answer by.
|
|
205
|
+
*
|
|
206
|
+
* Storage of the answer is always the emitting command's job (mirroring the
|
|
207
|
+
* deploy families), so responders never need to know what `scope:key` means —
|
|
208
|
+
* they just render and reply.
|
|
209
|
+
*/
|
|
210
|
+
export interface InterviewRequiredPayload {
|
|
211
|
+
/** Stable namespace for the question, e.g. `service:proxmox-home-lab`. */
|
|
212
|
+
scope: string;
|
|
213
|
+
/** Stable identifier within the scope, e.g. `default_target_node`. */
|
|
214
|
+
key: string;
|
|
215
|
+
kind: InterviewKind;
|
|
216
|
+
message: string;
|
|
217
|
+
required: boolean;
|
|
218
|
+
description?: string;
|
|
219
|
+
/** For `text`/`select`: the value used if the operator just hits Enter. For `confirm`: `'true'` | `'false'`. */
|
|
220
|
+
defaultValue?: string;
|
|
221
|
+
/** `text` only — grayed-out hint shown in the input field. */
|
|
222
|
+
placeholder?: string;
|
|
223
|
+
/** `select`/`multiselect` only — the choices. */
|
|
224
|
+
options?: Array<{ value: string; label: string; hint?: string }>;
|
|
225
|
+
/** `text` only — how to coerce the typed value. Defaults to `'string'`. */
|
|
226
|
+
type?: 'string' | 'integer' | 'number' | 'boolean';
|
|
227
|
+
/** `text` only — regex the typed value must match. */
|
|
228
|
+
pattern?: string;
|
|
229
|
+
/** Set on re-emits after a previous reply failed validation. */
|
|
230
|
+
previousError?: string;
|
|
231
|
+
/** 1-indexed attempt counter. Bounds prevent infinite re-emit loops. */
|
|
232
|
+
attempt?: number;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Reply to an `interview.required.*` query. `value`'s runtime shape follows
|
|
237
|
+
* the payload's `kind`: `string` (text/select), `string[]` (multiselect),
|
|
238
|
+
* `boolean` (confirm), or `number` (text with `type: integer|number`).
|
|
239
|
+
*/
|
|
240
|
+
export interface InterviewReply {
|
|
241
|
+
value: unknown;
|
|
242
|
+
}
|
|
243
|
+
|
|
185
244
|
/**
|
|
186
245
|
* Emit a query event on the bus and wait for a responder to reply.
|
|
187
246
|
*
|
|
@@ -237,3 +296,176 @@ export async function busInterviewGuarded<TReply>(
|
|
|
237
296
|
await ensureResponderForInterview(type);
|
|
238
297
|
return busInterview<TReply>(type, payload, ownerBus);
|
|
239
298
|
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Ask a single generic interview question over the bus and return the
|
|
302
|
+
* responder's answer (ISS-0127). The generic counterpart to the deploy's
|
|
303
|
+
* config/secret/ensure interview: any operator command can call this to make
|
|
304
|
+
* its prompts headlessly drivable instead of calling clack directly.
|
|
305
|
+
*
|
|
306
|
+
* The return type is `unknown` because the runtime shape depends on
|
|
307
|
+
* `payload.kind`; prefer the typed wrappers (`askText`, `askSelect`,
|
|
308
|
+
* `askMultiselect`, `askConfirm`) at call sites — they narrow it for you.
|
|
309
|
+
*
|
|
310
|
+
* Guarded like the deploy families: a non-TTY caller with no responder
|
|
311
|
+
* listening fails fast instead of hanging forever.
|
|
312
|
+
*/
|
|
313
|
+
export async function askInterview(
|
|
314
|
+
payload: InterviewRequiredPayload,
|
|
315
|
+
ownerBus?: Bus,
|
|
316
|
+
): Promise<unknown> {
|
|
317
|
+
const reply = await busInterviewGuarded<InterviewReply>(
|
|
318
|
+
EVENT_TYPES.interviewRequired(payload.scope, payload.key),
|
|
319
|
+
payload,
|
|
320
|
+
ownerBus,
|
|
321
|
+
);
|
|
322
|
+
return reply.value;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Run `fn` with a terminal-responder active when stdin is a TTY (ISS-0127).
|
|
327
|
+
*
|
|
328
|
+
* Every operator command that asks questions over the bus must, on a TTY,
|
|
329
|
+
* run a responder itself to render those questions — `askInterview`'s guard
|
|
330
|
+
* (`ensureResponderForInterview`) assumes one is already up when stdin is a
|
|
331
|
+
* TTY (it skips the no-responder check). On a non-TTY run no responder is
|
|
332
|
+
* started; the headless responder (`events respond --values` / `events
|
|
333
|
+
* reply`) answers instead, and the guard fails fast if none is listening.
|
|
334
|
+
*
|
|
335
|
+
* This is the shared lifecycle every migrated command wraps its interview in,
|
|
336
|
+
* so the start/close boilerplate lives in exactly one place. The dynamic
|
|
337
|
+
* import keeps clack out of the non-TTY path's module graph.
|
|
338
|
+
*/
|
|
339
|
+
export async function withInterviewSession<T>(fn: () => Promise<T>): Promise<T> {
|
|
340
|
+
const responder = process.stdin.isTTY
|
|
341
|
+
? (await import('./terminal-responder')).startTerminalResponder()
|
|
342
|
+
: null;
|
|
343
|
+
try {
|
|
344
|
+
return await fn();
|
|
345
|
+
} finally {
|
|
346
|
+
responder?.close();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Ask for a free-text value over the bus. Returns the coerced answer as a
|
|
352
|
+
* string (the responder coerces `integer`/`number`/`boolean` per `type`, but
|
|
353
|
+
* the value still arrives as a JSON scalar; callers that want a number should
|
|
354
|
+
* pass `type` and read it as such — here we narrow to the string form that
|
|
355
|
+
* the existing `promptText` call sites expect).
|
|
356
|
+
*/
|
|
357
|
+
export async function askText(opts: {
|
|
358
|
+
scope: string;
|
|
359
|
+
key: string;
|
|
360
|
+
message: string;
|
|
361
|
+
description?: string;
|
|
362
|
+
defaultValue?: string;
|
|
363
|
+
placeholder?: string;
|
|
364
|
+
required?: boolean;
|
|
365
|
+
type?: 'string' | 'integer' | 'number' | 'boolean';
|
|
366
|
+
pattern?: string;
|
|
367
|
+
}): Promise<string> {
|
|
368
|
+
const value = await askInterview({
|
|
369
|
+
scope: opts.scope,
|
|
370
|
+
key: opts.key,
|
|
371
|
+
kind: 'text',
|
|
372
|
+
message: opts.message,
|
|
373
|
+
required: opts.required ?? false,
|
|
374
|
+
description: opts.description,
|
|
375
|
+
defaultValue: opts.defaultValue,
|
|
376
|
+
placeholder: opts.placeholder,
|
|
377
|
+
type: opts.type,
|
|
378
|
+
pattern: opts.pattern,
|
|
379
|
+
});
|
|
380
|
+
if (typeof value === 'string') return value;
|
|
381
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
382
|
+
throw new Error(
|
|
383
|
+
`askText(${opts.scope}.${opts.key}): expected a string/number/boolean reply, got ${typeof value}`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Ask the operator to pick one of `options` over the bus. Returns the chosen
|
|
389
|
+
* option's `value`.
|
|
390
|
+
*/
|
|
391
|
+
export async function askSelect(opts: {
|
|
392
|
+
scope: string;
|
|
393
|
+
key: string;
|
|
394
|
+
message: string;
|
|
395
|
+
options: Array<{ value: string; label: string; hint?: string }>;
|
|
396
|
+
defaultValue?: string;
|
|
397
|
+
description?: string;
|
|
398
|
+
}): Promise<string> {
|
|
399
|
+
const value = await askInterview({
|
|
400
|
+
scope: opts.scope,
|
|
401
|
+
key: opts.key,
|
|
402
|
+
kind: 'select',
|
|
403
|
+
message: opts.message,
|
|
404
|
+
required: true,
|
|
405
|
+
options: opts.options,
|
|
406
|
+
defaultValue: opts.defaultValue,
|
|
407
|
+
description: opts.description,
|
|
408
|
+
});
|
|
409
|
+
if (typeof value !== 'string') {
|
|
410
|
+
throw new Error(
|
|
411
|
+
`askSelect(${opts.scope}.${opts.key}): expected a string reply, got ${typeof value}`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
return value;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Ask the operator to pick zero-or-more of `options` over the bus. Returns the
|
|
419
|
+
* chosen options' `value`s.
|
|
420
|
+
*/
|
|
421
|
+
export async function askMultiselect(opts: {
|
|
422
|
+
scope: string;
|
|
423
|
+
key: string;
|
|
424
|
+
message: string;
|
|
425
|
+
options: Array<{ value: string; label: string; hint?: string }>;
|
|
426
|
+
required?: boolean;
|
|
427
|
+
description?: string;
|
|
428
|
+
}): Promise<string[]> {
|
|
429
|
+
const value = await askInterview({
|
|
430
|
+
scope: opts.scope,
|
|
431
|
+
key: opts.key,
|
|
432
|
+
kind: 'multiselect',
|
|
433
|
+
message: opts.message,
|
|
434
|
+
required: opts.required ?? false,
|
|
435
|
+
options: opts.options,
|
|
436
|
+
description: opts.description,
|
|
437
|
+
});
|
|
438
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`askMultiselect(${opts.scope}.${opts.key}): expected a string[] reply, got ${typeof value}`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
return value as string[];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Ask a yes/no question over the bus. Returns the operator's boolean answer.
|
|
448
|
+
*/
|
|
449
|
+
export async function askConfirm(opts: {
|
|
450
|
+
scope: string;
|
|
451
|
+
key: string;
|
|
452
|
+
message: string;
|
|
453
|
+
defaultValue?: boolean;
|
|
454
|
+
description?: string;
|
|
455
|
+
}): Promise<boolean> {
|
|
456
|
+
const value = await askInterview({
|
|
457
|
+
scope: opts.scope,
|
|
458
|
+
key: opts.key,
|
|
459
|
+
kind: 'confirm',
|
|
460
|
+
message: opts.message,
|
|
461
|
+
required: true,
|
|
462
|
+
defaultValue: opts.defaultValue === undefined ? undefined : String(opts.defaultValue),
|
|
463
|
+
description: opts.description,
|
|
464
|
+
});
|
|
465
|
+
if (typeof value !== 'boolean') {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`askConfirm(${opts.scope}.${opts.key}): expected a boolean reply, got ${typeof value}`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
return value;
|
|
471
|
+
}
|
|
@@ -350,7 +350,7 @@ describe('bus-mediated interviewForMissingSecrets', () => {
|
|
|
350
350
|
const { seen, close } = startTestResponder(
|
|
351
351
|
bus,
|
|
352
352
|
'secret.required.testmod.ddns_passwords',
|
|
353
|
-
{ value: JSON.stringify({ '
|
|
353
|
+
{ value: JSON.stringify({ 'example.net': 'pw1', 'celilo.computer': 'pw2' }) },
|
|
354
354
|
testDb,
|
|
355
355
|
);
|
|
356
356
|
|
|
@@ -380,7 +380,7 @@ describe('bus-mediated interviewForMissingSecrets', () => {
|
|
|
380
380
|
|
|
381
381
|
const masterKey = await getOrCreateMasterKey();
|
|
382
382
|
const stored = await readModuleSecretKey('testmod', 'ddns_passwords', testDb, masterKey);
|
|
383
|
-
expect(stored).toBe(JSON.stringify({ '
|
|
383
|
+
expect(stored).toBe(JSON.stringify({ 'example.net': 'pw1', 'celilo.computer': 'pw2' }));
|
|
384
384
|
|
|
385
385
|
close();
|
|
386
386
|
});
|
|
@@ -18,6 +18,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
|
18
18
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
19
|
import { tmpdir } from 'node:os';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
|
+
import { skipIntegration } from '../test-utils/integration-guard';
|
|
21
22
|
|
|
22
23
|
import type { HookContext } from '@celilo/capabilities';
|
|
23
24
|
|
|
@@ -77,7 +78,7 @@ function buildContext(extras: Record<string, unknown>): HookContext {
|
|
|
77
78
|
} as HookContext;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
describe('celilo-mgmt on_backup', () => {
|
|
81
|
+
describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', () => {
|
|
81
82
|
let dir: string;
|
|
82
83
|
let backupDir: string;
|
|
83
84
|
let crossModuleRoot: string;
|
|
@@ -214,7 +215,7 @@ describe('celilo-mgmt on_backup', () => {
|
|
|
214
215
|
});
|
|
215
216
|
});
|
|
216
217
|
|
|
217
|
-
describe('celilo-mgmt on_restore', () => {
|
|
218
|
+
describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_restore', () => {
|
|
218
219
|
let dir: string;
|
|
219
220
|
let restoreDir: string;
|
|
220
221
|
let crossModuleWriteRoot: string;
|
|
@@ -29,6 +29,7 @@ import { capabilities, moduleConfigs, modules, secrets } from '../db/schema';
|
|
|
29
29
|
import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
|
|
30
30
|
import { buildResolutionContext } from '../variables/context';
|
|
31
31
|
import { E2E_CONFLICT_FIX, runningE2eContainers } from './e2e-guard';
|
|
32
|
+
import { describeCapabilityProblem, findBrokenCapabilityDerivations } from './fleet-checks';
|
|
32
33
|
|
|
33
34
|
export interface PreflightResult {
|
|
34
35
|
success: boolean;
|
|
@@ -232,6 +233,30 @@ export async function runPreflight(
|
|
|
232
233
|
});
|
|
233
234
|
}
|
|
234
235
|
}
|
|
236
|
+
|
|
237
|
+
// 4b. Capability-derived variables resolve to a concrete value. The
|
|
238
|
+
// scan above only catches values that ARE present-but-templated; a
|
|
239
|
+
// required `source: capability` var whose chain is broken upstream is
|
|
240
|
+
// silently DROPPED during derivation (the hasUnresolved guard), so it's
|
|
241
|
+
// absent from selfConfig entirely and a template `$self:<x>` later dies
|
|
242
|
+
// with a cryptic "not found". Assert it here against the RESOLVED
|
|
243
|
+
// capabilities map so the operator gets the named missing link up front
|
|
244
|
+
// instead of at generate time (ISS-0095 / ISS-0115).
|
|
245
|
+
for (const problem of findBrokenCapabilityDerivations(
|
|
246
|
+
moduleId,
|
|
247
|
+
manifest,
|
|
248
|
+
context.capabilities,
|
|
249
|
+
)) {
|
|
250
|
+
errors.push({
|
|
251
|
+
category: problem.reason === 'no-provider' ? 'missing-capability' : 'unresolved-template',
|
|
252
|
+
message: describeCapabilityProblem(problem),
|
|
253
|
+
variable: problem.variable,
|
|
254
|
+
suggestion:
|
|
255
|
+
problem.reason === 'no-provider'
|
|
256
|
+
? `Deploy a module that provides '${problem.capability}', then redeploy '${moduleId}'`
|
|
257
|
+
: `Redeploy '${problem.capability}'s provider so it populates ${problem.capability}.${problem.path}, then redeploy '${moduleId}'`,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
235
260
|
} catch (error) {
|
|
236
261
|
// Resolution context may fail — that's informative too
|
|
237
262
|
warnings.push({
|
|
@@ -15,14 +15,14 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
18
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
18
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
19
|
import { tmpdir } from 'node:os';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
import { type DbClient, getDb } from '../db/client';
|
|
22
22
|
import { modules, secrets } from '../db/schema';
|
|
23
23
|
import type { ModuleManifest } from '../manifest/schema';
|
|
24
24
|
import { findMissingSecrets } from './config-interview';
|
|
25
|
-
import { findMissingRequiredVariables } from './deploy-validation';
|
|
25
|
+
import { findMissingBuildArtifacts, findMissingRequiredVariables } from './deploy-validation';
|
|
26
26
|
|
|
27
27
|
function manifestWithStringMapSecret(): ModuleManifest {
|
|
28
28
|
return {
|
|
@@ -265,8 +265,8 @@ describe('findMissingSecrets (shared)', () => {
|
|
|
265
265
|
// The terminal-responder reads these off the bus payload to apply
|
|
266
266
|
// input-time regex validation. Without manifest → MissingVariable
|
|
267
267
|
// propagation, the responder never sees them and operators end
|
|
268
|
-
// up entering invalid keys (e.g. 'www.
|
|
269
|
-
// the apex '
|
|
268
|
+
// up entering invalid keys (e.g. 'www.example.net' instead of
|
|
269
|
+
// the apex 'example.net').
|
|
270
270
|
const missing = await findMissingSecrets(
|
|
271
271
|
'testmod',
|
|
272
272
|
{
|
|
@@ -293,3 +293,53 @@ describe('findMissingSecrets (shared)', () => {
|
|
|
293
293
|
expect(missing[0].value_pattern_message).toBe('min 8 chars');
|
|
294
294
|
});
|
|
295
295
|
});
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The deploy build-gate: the management server does NOT build modules from
|
|
299
|
+
* source. A module's declared artifacts must already be present on disk
|
|
300
|
+
* (shipped in the .netapp at `module package` / `module publish` time). Deploy
|
|
301
|
+
* verifies them; a missing artifact is a hard error, never a from-source
|
|
302
|
+
* rebuild on the management box (ISS-0131).
|
|
303
|
+
*/
|
|
304
|
+
describe('findMissingBuildArtifacts (deploy build-gate)', () => {
|
|
305
|
+
let dir: string;
|
|
306
|
+
|
|
307
|
+
beforeEach(() => {
|
|
308
|
+
dir = mkdtempSync(join(tmpdir(), 'build-gate-'));
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
afterEach(() => {
|
|
312
|
+
rmSync(dir, { recursive: true, force: true });
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
function manifestWithArtifacts(artifacts: string[]): ModuleManifest {
|
|
316
|
+
return {
|
|
317
|
+
celilo_contract: '1.0',
|
|
318
|
+
id: 'buildmod',
|
|
319
|
+
name: 'Build Module',
|
|
320
|
+
version: '1.0.0',
|
|
321
|
+
description: 'fixture',
|
|
322
|
+
build: { command: 'true', artifacts },
|
|
323
|
+
} as unknown as ModuleManifest;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
test('returns [] when the module declares no build section', () => {
|
|
327
|
+
const manifest = { celilo_contract: '1.0', id: 'm', version: '1.0.0' } as ModuleManifest;
|
|
328
|
+
expect(findMissingBuildArtifacts(manifest, dir)).toEqual([]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('returns [] when all declared artifacts exist on disk (prebuilt .netapp)', () => {
|
|
332
|
+
mkdirSync(join(dir, 'dist'), { recursive: true });
|
|
333
|
+
writeFileSync(join(dir, 'dist', 'server'), 'binary');
|
|
334
|
+
writeFileSync(join(dir, 'dist', 'index.html'), '<html>');
|
|
335
|
+
const manifest = manifestWithArtifacts(['dist/server', 'dist/index.html']);
|
|
336
|
+
expect(findMissingBuildArtifacts(manifest, dir)).toEqual([]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('returns the missing artifacts when some are absent (no mgmt-server rebuild)', () => {
|
|
340
|
+
mkdirSync(join(dir, 'dist'), { recursive: true });
|
|
341
|
+
writeFileSync(join(dir, 'dist', 'server'), 'binary');
|
|
342
|
+
const manifest = manifestWithArtifacts(['dist/server', 'dist/index.html', 'dist/db.sqlite']);
|
|
343
|
+
expect(findMissingBuildArtifacts(manifest, dir)).toEqual(['dist/index.html', 'dist/db.sqlite']);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
@@ -14,14 +14,12 @@ import type { ModuleManifest } from '../manifest/schema';
|
|
|
14
14
|
import { isPrivilegedCapability } from '../manifest/validate';
|
|
15
15
|
import { generateTemplates } from '../templates/generator';
|
|
16
16
|
import { findMissingSecrets } from './config-interview';
|
|
17
|
-
import { buildModuleFromSource, getModuleBuildStatus, verifyArtifactsExist } from './module-build';
|
|
18
17
|
|
|
19
18
|
export interface ValidationResult {
|
|
20
19
|
success: boolean;
|
|
21
20
|
error?: string;
|
|
22
21
|
warnings?: string[];
|
|
23
22
|
autoGenerated?: boolean;
|
|
24
|
-
autoBuilt?: boolean;
|
|
25
23
|
missingVariables?: Array<{
|
|
26
24
|
name: string;
|
|
27
25
|
source: 'user' | 'secret' | 'capability' | 'system';
|
|
@@ -41,9 +39,27 @@ export interface ValidationResult {
|
|
|
41
39
|
}>;
|
|
42
40
|
}
|
|
43
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Declared build artifacts that are missing on disk.
|
|
44
|
+
*
|
|
45
|
+
* Modules are built when their package is created: `module package` /
|
|
46
|
+
* `module publish` runs `manifest.build.command` and bakes the declared
|
|
47
|
+
* artifacts into the .netapp (cross-arch binaries and all). By deploy time —
|
|
48
|
+
* from the registry in production, or via `celilo package` in e2e — those
|
|
49
|
+
* artifacts are already on disk, so deploy only VERIFIES them. An empty result
|
|
50
|
+
* means the module is built; a non-empty result is a hard deploy error, because
|
|
51
|
+
* the management server does NOT build from source (ISS-0131: a from-source
|
|
52
|
+
* rebuild on the 3.7 GB management box deadlocked at `bun install`, then
|
|
53
|
+
* OOM-crashed nx workers, despite the .netapp shipping the binaries).
|
|
54
|
+
*/
|
|
55
|
+
export function findMissingBuildArtifacts(manifest: ModuleManifest, sourcePath: string): string[] {
|
|
56
|
+
const declared = manifest.build?.artifacts ?? [];
|
|
57
|
+
return declared.filter((rel) => !existsSync(join(sourcePath, rel)));
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
/**
|
|
45
61
|
* Validate module is ready for deployment and auto-prepare if needed
|
|
46
|
-
* Policy + Execution function - checks prerequisites and runs generate
|
|
62
|
+
* Policy + Execution function - checks prerequisites and runs generate if needed
|
|
47
63
|
*
|
|
48
64
|
* @param moduleId - Module identifier
|
|
49
65
|
* @param db - Database connection
|
|
@@ -54,7 +70,6 @@ export async function validateAndPrepareDeployment(
|
|
|
54
70
|
db: DbClient,
|
|
55
71
|
): Promise<ValidationResult> {
|
|
56
72
|
let autoGenerated = false;
|
|
57
|
-
let autoBuilt = false;
|
|
58
73
|
|
|
59
74
|
// Check module exists
|
|
60
75
|
const module = await db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
@@ -107,7 +122,6 @@ export async function validateAndPrepareDeployment(
|
|
|
107
122
|
return {
|
|
108
123
|
success: true,
|
|
109
124
|
autoGenerated: false,
|
|
110
|
-
autoBuilt: false,
|
|
111
125
|
missingVariables,
|
|
112
126
|
};
|
|
113
127
|
}
|
|
@@ -131,42 +145,19 @@ export async function validateAndPrepareDeployment(
|
|
|
131
145
|
|
|
132
146
|
autoGenerated = true;
|
|
133
147
|
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
// let the deploy run on without binaries — exactly how Ansible ends up
|
|
143
|
-
// failing on a missing `src:` file.
|
|
144
|
-
const declaredArtifacts = manifest.build.artifacts ?? [];
|
|
145
|
-
const declaredArtifactsOk = declaredArtifacts.every((rel) =>
|
|
146
|
-
existsSync(join(module.sourcePath, rel)),
|
|
147
|
-
);
|
|
148
|
-
const needsBuild =
|
|
149
|
-
!buildStatus ||
|
|
150
|
-
buildStatus.status !== 'success' ||
|
|
151
|
-
!verifyArtifactsExist(buildStatus.artifacts) ||
|
|
152
|
-
(declaredArtifacts.length > 0 && !declaredArtifactsOk);
|
|
153
|
-
|
|
154
|
-
if (needsBuild) {
|
|
155
|
-
const buildResult = await buildModuleFromSource(moduleId, db);
|
|
156
|
-
if (!buildResult.success) {
|
|
157
|
-
return {
|
|
158
|
-
success: false,
|
|
159
|
-
error: `Auto-build failed: ${buildResult.error || 'Build failed'}`,
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
autoBuilt = true;
|
|
163
|
-
}
|
|
148
|
+
// Verify build artifacts are present — the management server does NOT build
|
|
149
|
+
// modules from source (see findMissingBuildArtifacts / ISS-0131).
|
|
150
|
+
const missingArtifacts = findMissingBuildArtifacts(manifest, module.sourcePath);
|
|
151
|
+
if (missingArtifacts.length > 0) {
|
|
152
|
+
return {
|
|
153
|
+
success: false,
|
|
154
|
+
error: `Module '${moduleId}' is missing build artifacts that must ship in its package:\n${missingArtifacts.map((m) => ` - ${m}`).join('\n')}\n\nThe management server does not build modules from source. Build where the module is authored, then republish and update:\n celilo module publish <path> (or: celilo module build ${moduleId})\n celilo module update`,
|
|
155
|
+
};
|
|
164
156
|
}
|
|
165
157
|
|
|
166
158
|
return {
|
|
167
159
|
success: true,
|
|
168
160
|
autoGenerated,
|
|
169
|
-
autoBuilt,
|
|
170
161
|
};
|
|
171
162
|
}
|
|
172
163
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type { DnsRecordRequest } from '@celilo/capabilities';
|
|
6
|
+
import type { DbClient } from '../db/client';
|
|
7
|
+
import { modules } from '../db/schema';
|
|
8
|
+
import { setupTestDatabase } from '../test-utils/setup-test-db';
|
|
9
|
+
import {
|
|
10
|
+
listDnsInternalRecords,
|
|
11
|
+
recordDnsInternalRecord,
|
|
12
|
+
removeDnsInternalRecord,
|
|
13
|
+
withDnsInternalLedger,
|
|
14
|
+
} from './dns-internal-records';
|
|
15
|
+
|
|
16
|
+
describe('dns-internal-records ledger', () => {
|
|
17
|
+
let dir: string;
|
|
18
|
+
let dbPath: string;
|
|
19
|
+
let db: DbClient;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
dir = mkdtempSync(join(tmpdir(), 'dns-int-'));
|
|
23
|
+
dbPath = join(dir, 'celilo.db');
|
|
24
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
25
|
+
db = await setupTestDatabase(dbPath);
|
|
26
|
+
// FK targets: provider + consumer modules.
|
|
27
|
+
for (const id of ['technitium', 'forgejo']) {
|
|
28
|
+
db.insert(modules)
|
|
29
|
+
.values({
|
|
30
|
+
id,
|
|
31
|
+
name: id,
|
|
32
|
+
version: '1.0.0',
|
|
33
|
+
state: 'VERIFIED',
|
|
34
|
+
manifestData: {},
|
|
35
|
+
sourcePath: `/src/${id}`,
|
|
36
|
+
})
|
|
37
|
+
.run();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
db.$client.close();
|
|
42
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
43
|
+
try {
|
|
44
|
+
rmSync(dir, { recursive: true, force: true });
|
|
45
|
+
} catch {
|
|
46
|
+
/* ignore */
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const ctx = () => ({ db, providerModuleId: 'technitium', consumerModuleId: 'forgejo' });
|
|
51
|
+
|
|
52
|
+
it('records, lists, and upserts by (provider, host)', () => {
|
|
53
|
+
recordDnsInternalRecord(db, { ...ctx(), host: 'git-ssh.x', ip: '10.0.20.14' });
|
|
54
|
+
let rows = listDnsInternalRecords(db);
|
|
55
|
+
expect(rows).toHaveLength(1);
|
|
56
|
+
expect(rows[0].ip).toBe('10.0.20.14');
|
|
57
|
+
|
|
58
|
+
// Same (provider, host) updates in place — the natIp fix re-registering.
|
|
59
|
+
recordDnsInternalRecord(db, { ...ctx(), host: 'git-ssh.x', ip: '192.168.0.253' });
|
|
60
|
+
rows = listDnsInternalRecords(db);
|
|
61
|
+
expect(rows).toHaveLength(1);
|
|
62
|
+
expect(rows[0].ip).toBe('192.168.0.253');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('removes a record by (provider, host)', () => {
|
|
66
|
+
recordDnsInternalRecord(db, { ...ctx(), host: 'a.x', ip: '1.1.1.1' });
|
|
67
|
+
recordDnsInternalRecord(db, { ...ctx(), host: 'b.x', ip: '2.2.2.2' });
|
|
68
|
+
removeDnsInternalRecord(db, { providerModuleId: 'technitium', host: 'a.x' });
|
|
69
|
+
const rows = listDnsInternalRecords(db);
|
|
70
|
+
expect(rows.map((r) => r.host)).toEqual(['b.x']);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('withDnsInternalLedger', () => {
|
|
74
|
+
function fakeProvider() {
|
|
75
|
+
const calls: Array<['register' | 'delete', DnsRecordRequest]> = [];
|
|
76
|
+
const iface = {
|
|
77
|
+
async registerRecord(req: DnsRecordRequest) {
|
|
78
|
+
calls.push(['register', req]);
|
|
79
|
+
},
|
|
80
|
+
async deleteRecord(req: DnsRecordRequest) {
|
|
81
|
+
calls.push(['delete', req]);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
return { iface, calls };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
it('records A-record registrations and passes the call through', async () => {
|
|
88
|
+
const { iface, calls } = fakeProvider();
|
|
89
|
+
const wrapped = withDnsInternalLedger(iface, ctx());
|
|
90
|
+
await wrapped.registerRecord({ host: 'git-ssh.x', type: 'A', value: '192.168.0.253' });
|
|
91
|
+
expect(calls).toHaveLength(1); // underlying provider still called
|
|
92
|
+
const rows = listDnsInternalRecords(db);
|
|
93
|
+
expect(rows).toHaveLength(1);
|
|
94
|
+
expect(rows[0]).toMatchObject({ host: 'git-ssh.x', ip: '192.168.0.253' });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('does NOT ledger non-A records', async () => {
|
|
98
|
+
const { iface } = fakeProvider();
|
|
99
|
+
const wrapped = withDnsInternalLedger(iface, ctx());
|
|
100
|
+
await wrapped.registerRecord({ host: 'mail.x', type: 'MX', value: 'mx.x' });
|
|
101
|
+
expect(listDnsInternalRecords(db)).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('removes the ledger row on A-record delete', async () => {
|
|
105
|
+
const { iface } = fakeProvider();
|
|
106
|
+
const wrapped = withDnsInternalLedger(iface, ctx());
|
|
107
|
+
await wrapped.registerRecord({ host: 'git-ssh.x', type: 'A', value: '192.168.0.253' });
|
|
108
|
+
await wrapped.deleteRecord({ host: 'git-ssh.x', type: 'A', value: '192.168.0.253' });
|
|
109
|
+
expect(listDnsInternalRecords(db)).toHaveLength(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('does not write the ledger if the underlying register throws', async () => {
|
|
113
|
+
const iface = {
|
|
114
|
+
async registerRecord(): Promise<void> {
|
|
115
|
+
throw new Error('resolver down');
|
|
116
|
+
},
|
|
117
|
+
async deleteRecord(): Promise<void> {},
|
|
118
|
+
};
|
|
119
|
+
const wrapped = withDnsInternalLedger(iface, ctx());
|
|
120
|
+
await expect(
|
|
121
|
+
wrapped.registerRecord({ host: 'git-ssh.x', type: 'A', value: '1.2.3.4' }),
|
|
122
|
+
).rejects.toThrow('resolver down');
|
|
123
|
+
expect(listDnsInternalRecords(db)).toHaveLength(0);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|