@celilo/cli 0.1.5 → 0.1.6
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/0004_caddy_hostname_list.sql +25 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +9 -2
- package/src/ansible/inventory.test.ts +3 -2
- package/src/ansible/inventory.ts +5 -1
- package/src/capabilities/public-web-helpers.test.ts +2 -2
- package/src/capabilities/public-web-publish.test.ts +34 -1
- package/src/cli/cli.test.ts +2 -2
- package/src/cli/command-registry.ts +146 -3
- package/src/cli/command-tree-parser.test.ts +1 -1
- package/src/cli/command-tree-parser.ts +9 -8
- package/src/cli/commands/hook-run.ts +15 -66
- package/src/cli/commands/module-audit.ts +14 -44
- package/src/cli/commands/module-deploy.ts +4 -1
- package/src/cli/commands/module-import-registry.test.ts +115 -0
- package/src/cli/commands/module-import.ts +106 -22
- package/src/cli/commands/module-publish.test.ts +235 -0
- package/src/cli/commands/module-publish.ts +234 -0
- package/src/cli/commands/module-remove.ts +82 -2
- package/src/cli/commands/module-search.ts +57 -0
- package/src/cli/commands/module-secret-get.ts +59 -0
- package/src/cli/commands/module-terraform-unlock.ts +57 -0
- package/src/cli/commands/module-verify.test.ts +59 -0
- package/src/cli/commands/module-verify.ts +53 -0
- package/src/cli/commands/status.ts +30 -20
- package/src/cli/commands/system-audit.test.ts +138 -0
- package/src/cli/commands/system-audit.ts +571 -0
- package/src/cli/commands/system-update.ts +391 -0
- package/src/cli/completion.ts +15 -1
- package/src/cli/fuel-gauge.ts +68 -3
- package/src/cli/generate-zsh-completion.ts +13 -3
- package/src/cli/index.ts +112 -5
- package/src/cli/parser.ts +11 -0
- package/src/cli/prompts.ts +36 -5
- package/src/cli/tui/audit-state.test.ts +246 -0
- package/src/cli/tui/audit-state.ts +525 -0
- package/src/cli/tui/audit-tui.test.tsx +135 -0
- package/src/cli/tui/audit-tui.tsx +624 -0
- package/src/cli/tui/celebration.tsx +29 -0
- package/src/cli/tui/clipboard.test.ts +94 -0
- package/src/cli/tui/clipboard.ts +101 -0
- package/src/cli/tui/icons.ts +22 -0
- package/src/cli/tui/keybar.tsx +65 -0
- package/src/cli/tui/keymap.test.ts +105 -0
- package/src/cli/tui/keymap.ts +70 -0
- package/src/cli/tui/modals/analyzing.tsx +75 -0
- package/src/cli/tui/modals/celebration.tsx +44 -0
- package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
- package/src/cli/tui/modals/remediate.tsx +44 -0
- package/src/cli/tui/modals.test.ts +137 -0
- package/src/cli/tui/mouse.test.ts +78 -0
- package/src/cli/tui/mouse.ts +114 -0
- package/src/cli/tui/panes/categories.tsx +62 -0
- package/src/cli/tui/panes/command-log.tsx +87 -0
- package/src/cli/tui/panes/detail.tsx +175 -0
- package/src/cli/tui/panes/findings.tsx +97 -0
- package/src/cli/tui/panes/summary.tsx +64 -0
- package/src/cli/tui/spawn.ts +130 -0
- package/src/cli/tui/theme.ts +42 -0
- package/src/cli/tui/wrap.test.ts +43 -0
- package/src/cli/tui/wrap.ts +45 -0
- package/src/cli/types.ts +5 -0
- package/src/db/client.ts +55 -2
- package/src/db/schema.ts +26 -17
- package/src/hooks/capability-loader.ts +133 -73
- package/src/hooks/define-hook.test.ts +9 -1
- package/src/hooks/executor.ts +22 -1
- package/src/hooks/load-hook-config.test.ts +165 -0
- package/src/hooks/load-hook-config.ts +60 -0
- package/src/hooks/logger.ts +42 -12
- package/src/hooks/run-named-hook.ts +128 -0
- package/src/hooks/types.ts +19 -0
- package/src/manifest/ensure-schema.test.ts +115 -0
- package/src/manifest/schema.ts +76 -0
- package/src/module/import.ts +20 -12
- package/src/module/packaging/build.ts +85 -16
- package/src/module/packaging/release-metadata.test.ts +103 -0
- package/src/module/packaging/release-metadata.ts +145 -0
- package/src/registry/client.test.ts +228 -0
- package/src/registry/client.ts +157 -0
- package/src/services/audit/backups.test.ts +233 -0
- package/src/services/audit/backups.ts +128 -0
- package/src/services/audit/capability-abi.test.ts +153 -0
- package/src/services/audit/capability-abi.ts +204 -0
- package/src/services/audit/cli-version.test.ts +60 -0
- package/src/services/audit/cli-version.ts +87 -0
- package/src/services/audit/health.test.ts +84 -0
- package/src/services/audit/health.ts +43 -0
- package/src/services/audit/index.test.ts +99 -0
- package/src/services/audit/index.ts +118 -0
- package/src/services/audit/machines-reachable.test.ts +87 -0
- package/src/services/audit/machines-reachable.ts +87 -0
- package/src/services/audit/module-configs.test.ts +131 -0
- package/src/services/audit/module-configs.ts +80 -0
- package/src/services/audit/module-versions.test.ts +99 -0
- package/src/services/audit/module-versions.ts +154 -0
- package/src/services/audit/schema.test.ts +68 -0
- package/src/services/audit/schema.ts +115 -0
- package/src/services/audit/secrets-decryptable.test.ts +82 -0
- package/src/services/audit/secrets-decryptable.ts +97 -0
- package/src/services/audit/services-credentials.test.ts +54 -0
- package/src/services/audit/services-credentials.ts +64 -0
- package/src/services/audit/services-reachable.test.ts +60 -0
- package/src/services/audit/services-reachable.ts +64 -0
- package/src/services/audit/terraform-plan.test.ts +127 -0
- package/src/services/audit/terraform-plan.ts +153 -0
- package/src/services/audit/types.test.ts +36 -0
- package/src/services/audit/types.ts +90 -0
- package/src/services/audit/unconfigured-modules.test.ts +48 -0
- package/src/services/audit/unconfigured-modules.ts +71 -0
- package/src/services/audit/undeployed-modules.test.ts +66 -0
- package/src/services/audit/undeployed-modules.ts +72 -0
- package/src/services/build-stream.ts +122 -122
- package/src/services/config-interview.ts +407 -2
- package/src/services/deploy-ansible.ts +73 -7
- package/src/services/deploy-preflight.ts +45 -4
- package/src/services/deploy-terraform.ts +31 -24
- package/src/services/deploy-validation.ts +167 -23
- package/src/services/ensure-interview.test.ts +245 -0
- package/src/services/health-runner.ts +110 -38
- package/src/services/module-build.ts +11 -13
- package/src/services/module-deploy.ts +370 -59
- package/src/services/ssh-key-manager.test.ts +1 -1
- package/src/services/ssh-key-manager.ts +3 -2
- package/src/services/terraform-env.ts +62 -0
- package/src/services/update/dep-graph.test.ts +214 -0
- package/src/services/update/dep-graph.ts +215 -0
- package/src/services/update/orchestrator.test.ts +463 -0
- package/src/services/update/orchestrator.ts +359 -0
- package/src/services/update/progress.ts +49 -0
- package/src/services/update/self-update.test.ts +68 -0
- package/src/services/update/self-update.ts +57 -0
- package/src/services/update/types.ts +94 -0
- package/src/templates/generator.test.ts +1 -1
- package/src/templates/generator.ts +42 -1
- package/src/test-utils/completion-harness.test.ts +1 -1
- package/src/test-utils/completion-harness.ts +4 -4
- package/src/variables/capability-self-ref.test.ts +203 -0
- package/src/variables/context.ts +49 -1
- package/src/variables/declarative-derivation.test.ts +306 -0
- package/src/variables/declarative-derivation.ts +4 -2
- package/src/variables/parser.test.ts +56 -1
- package/src/variables/parser.ts +47 -6
- package/src/variables/resolver.ts +27 -9
- package/tsconfig.json +1 -0
|
@@ -17,20 +17,13 @@ import {
|
|
|
17
17
|
isCompiledCapabilityFactory,
|
|
18
18
|
wrapWithLogging,
|
|
19
19
|
} from '@celilo/capabilities';
|
|
20
|
-
import type { HookLogger, RouteOps } from '@celilo/capabilities';
|
|
20
|
+
import type { DnsInternalCapability, HookLogger, RouteOps } from '@celilo/capabilities';
|
|
21
21
|
import { and, eq } from 'drizzle-orm';
|
|
22
22
|
import type { DbClient } from '../db/client';
|
|
23
|
-
import {
|
|
24
|
-
capabilities,
|
|
25
|
-
machines,
|
|
26
|
-
moduleConfigs,
|
|
27
|
-
moduleInfrastructure,
|
|
28
|
-
modules,
|
|
29
|
-
secrets,
|
|
30
|
-
webRoutes,
|
|
31
|
-
} from '../db/schema';
|
|
23
|
+
import { capabilities, modules, secrets, webRoutes } from '../db/schema';
|
|
32
24
|
import { decryptSecret } from '../secrets/encryption';
|
|
33
25
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
26
|
+
import { loadHookConfigMap } from './load-hook-config';
|
|
34
27
|
|
|
35
28
|
/**
|
|
36
29
|
* Mapping of capability names to their function module entry points.
|
|
@@ -110,41 +103,9 @@ export async function loadCapabilityFunctions(
|
|
|
110
103
|
`Loading capabilities. Registry has: public_web (framework), ${Object.keys(CAPABILITY_MODULE_MAP).join(', ')}`,
|
|
111
104
|
);
|
|
112
105
|
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
.from(capabilities)
|
|
117
|
-
.where(eq(capabilities.capabilityName, 'public_web'))
|
|
118
|
-
.all();
|
|
119
|
-
|
|
120
|
-
if (publicWebProviders.length > 0) {
|
|
121
|
-
const provider = publicWebProviders[0];
|
|
122
|
-
debugLog(`public_web: found provider module ${provider.moduleId}`);
|
|
123
|
-
const providerConfig = await loadModuleConfig(provider.moduleId, db);
|
|
124
|
-
const providerSecrets = await loadModuleSecrets(provider.moduleId, masterKey, db);
|
|
125
|
-
const routeOps = buildRouteOps(db);
|
|
126
|
-
|
|
127
|
-
try {
|
|
128
|
-
// createPublicWeb internally applies wrapWithLogging using the
|
|
129
|
-
// logger we pass in here, so the consumer doesn't need to wrap
|
|
130
|
-
// anything at this layer.
|
|
131
|
-
result.public_web = createPublicWeb({
|
|
132
|
-
moduleId: consumingModuleId,
|
|
133
|
-
logger,
|
|
134
|
-
config: providerConfig,
|
|
135
|
-
secrets: providerSecrets,
|
|
136
|
-
routeOps,
|
|
137
|
-
});
|
|
138
|
-
debugLog(`public_web: loaded via framework implementation for ${consumingModuleId}`);
|
|
139
|
-
} catch (error) {
|
|
140
|
-
debugLog(
|
|
141
|
-
`public_web: FAILED to load: ${error instanceof Error ? error.message : String(error)}`,
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
} else {
|
|
145
|
-
debugLog('public_web: not registered in DB, skipping');
|
|
146
|
-
}
|
|
147
|
-
|
|
106
|
+
// Load all non-public_web capabilities first so dns_internal (and others)
|
|
107
|
+
// are available when we construct the framework-owned public_web capability.
|
|
108
|
+
// public_web optionally threads dns_internal through for split-horizon DNS.
|
|
148
109
|
for (const [capName, moduleInfo] of Object.entries(CAPABILITY_MODULE_MAP)) {
|
|
149
110
|
// Check if this capability is registered (a provider module is deployed)
|
|
150
111
|
// For zone-scoped capabilities (like firewall), there may be multiple providers
|
|
@@ -251,6 +212,115 @@ export async function loadCapabilityFunctions(
|
|
|
251
212
|
}
|
|
252
213
|
}
|
|
253
214
|
|
|
215
|
+
// Handle public_web via framework implementation (no dynamic import needed).
|
|
216
|
+
// Loaded after the capability loop so dns_internal is already available to
|
|
217
|
+
// thread through for split-horizon DNS record registration.
|
|
218
|
+
const publicWebProviders = db
|
|
219
|
+
.select()
|
|
220
|
+
.from(capabilities)
|
|
221
|
+
.where(eq(capabilities.capabilityName, 'public_web'))
|
|
222
|
+
.all();
|
|
223
|
+
|
|
224
|
+
if (publicWebProviders.length > 0) {
|
|
225
|
+
const provider = publicWebProviders[0];
|
|
226
|
+
debugLog(`public_web: found provider module ${provider.moduleId}`);
|
|
227
|
+
const providerConfig = await loadModuleConfig(provider.moduleId, db);
|
|
228
|
+
const providerSecrets = await loadModuleSecrets(provider.moduleId, masterKey, db);
|
|
229
|
+
const routeOps = buildRouteOps(db);
|
|
230
|
+
|
|
231
|
+
// Find the firewall NAT IP so split-horizon records point to the iptables
|
|
232
|
+
// internal interface rather than Caddy's unreachable DMZ container IP.
|
|
233
|
+
const firewallProviders = db
|
|
234
|
+
.select()
|
|
235
|
+
.from(capabilities)
|
|
236
|
+
.where(eq(capabilities.capabilityName, 'firewall'))
|
|
237
|
+
.all();
|
|
238
|
+
let firewallNatIp: string | undefined;
|
|
239
|
+
for (const fp of firewallProviders) {
|
|
240
|
+
const fpConfig = await loadModuleConfig(fp.moduleId, db);
|
|
241
|
+
if (typeof fpConfig.nat_ip === 'string' && fpConfig.nat_ip) {
|
|
242
|
+
firewallNatIp = fpConfig.nat_ip;
|
|
243
|
+
debugLog(
|
|
244
|
+
`public_web: using firewall natIp ${firewallNatIp} from ${fp.moduleId} for internal DNS`,
|
|
245
|
+
);
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Caddy's configured hostnames — public_web rejects routes for any
|
|
251
|
+
// hostname not in this list, throwing a structured error that runs
|
|
252
|
+
// caddy's `managed_hostname` ensure interview. Empty list means
|
|
253
|
+
// caddy isn't configured yet — also a structured error path
|
|
254
|
+
// (caller will see it on the first register_route).
|
|
255
|
+
const caddyHostnames: string[] = [];
|
|
256
|
+
if (Array.isArray(providerConfig.hostnames)) {
|
|
257
|
+
for (const h of providerConfig.hostnames) {
|
|
258
|
+
if (typeof h === 'string' && h) caddyHostnames.push(h);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Domains managed by some dns_registrar (primary + additional).
|
|
263
|
+
// Each entry in caddy's hostnames must also be in this list — else
|
|
264
|
+
// DNS won't resolve and TLS will eventually break. The registrar's
|
|
265
|
+
// module id drives the second ensure interview when a hostname
|
|
266
|
+
// isn't covered.
|
|
267
|
+
const dnsRegistrarProviders = db
|
|
268
|
+
.select()
|
|
269
|
+
.from(capabilities)
|
|
270
|
+
.where(eq(capabilities.capabilityName, 'dns_registrar'))
|
|
271
|
+
.all();
|
|
272
|
+
const dnsManagedDomains: string[] = [];
|
|
273
|
+
let dnsRegistrarModuleId: string | undefined;
|
|
274
|
+
for (const drp of dnsRegistrarProviders) {
|
|
275
|
+
// First registrar wins — multi-registrar setups can't be auto-extended
|
|
276
|
+
// since we'd have to pick which one to add the new domain to.
|
|
277
|
+
if (!dnsRegistrarModuleId) dnsRegistrarModuleId = drp.moduleId;
|
|
278
|
+
const drConfig = await loadModuleConfig(drp.moduleId, db);
|
|
279
|
+
// dns_registrar 4.0.0+ exposes a single `domains` array (the
|
|
280
|
+
// primary_domain/additional_domains split was collapsed — see
|
|
281
|
+
// apps/celilo/designs/DNS_REGISTRAR_MULTI_DOMAIN.md). Older
|
|
282
|
+
// registrars that haven't been migrated still use the split shape;
|
|
283
|
+
// we keep that fallback so a stale config can still be read.
|
|
284
|
+
if (Array.isArray(drConfig.domains)) {
|
|
285
|
+
for (const d of drConfig.domains) {
|
|
286
|
+
if (typeof d === 'string' && d) dnsManagedDomains.push(d);
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
if (typeof drConfig.primary_domain === 'string' && drConfig.primary_domain) {
|
|
290
|
+
dnsManagedDomains.push(drConfig.primary_domain);
|
|
291
|
+
}
|
|
292
|
+
if (Array.isArray(drConfig.additional_domains)) {
|
|
293
|
+
for (const d of drConfig.additional_domains) {
|
|
294
|
+
if (typeof d === 'string' && d) dnsManagedDomains.push(d);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
result.public_web = createPublicWeb({
|
|
302
|
+
moduleId: consumingModuleId,
|
|
303
|
+
logger,
|
|
304
|
+
config: providerConfig,
|
|
305
|
+
secrets: providerSecrets,
|
|
306
|
+
routeOps,
|
|
307
|
+
dnsInternal: result.dns_internal as DnsInternalCapability | undefined,
|
|
308
|
+
firewallNatIp,
|
|
309
|
+
hostnames: caddyHostnames,
|
|
310
|
+
caddyModuleId: provider.moduleId,
|
|
311
|
+
dnsManagedDomains,
|
|
312
|
+
dnsRegistrarModuleId,
|
|
313
|
+
});
|
|
314
|
+
debugLog(`public_web: loaded via framework implementation for ${consumingModuleId}`);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
debugLog(
|
|
317
|
+
`public_web: FAILED to load: ${error instanceof Error ? error.message : String(error)}`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
} else {
|
|
321
|
+
debugLog('public_web: not registered in DB, skipping');
|
|
322
|
+
}
|
|
323
|
+
|
|
254
324
|
return result;
|
|
255
325
|
}
|
|
256
326
|
|
|
@@ -294,33 +364,15 @@ function buildCapabilityInterface(
|
|
|
294
364
|
/**
|
|
295
365
|
* Load all config values for a module.
|
|
296
366
|
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
367
|
+
* Thin wrapper around `loadHookConfigMap` — kept private here so
|
|
368
|
+
* existing callers in this file don't need an import-path churn. The
|
|
369
|
+
* shared helper handles the `target_ip` / `ip.primary` machine-IP
|
|
370
|
+
* fallback for machine deploys (IPAM writes those keys to
|
|
371
|
+
* `module_configs` for container deploys, but machine deploys skip
|
|
372
|
+
* that write).
|
|
300
373
|
*/
|
|
301
374
|
async function loadModuleConfig(moduleId: string, db: DbClient): Promise<Record<string, unknown>> {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const result: Record<string, unknown> = {};
|
|
305
|
-
for (const c of configs) {
|
|
306
|
-
result[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
if (!result.target_ip) {
|
|
310
|
-
const infra = db
|
|
311
|
-
.select()
|
|
312
|
-
.from(moduleInfrastructure)
|
|
313
|
-
.where(eq(moduleInfrastructure.moduleId, moduleId))
|
|
314
|
-
.get();
|
|
315
|
-
if (infra?.machineId) {
|
|
316
|
-
const machine = db.select().from(machines).where(eq(machines.id, infra.machineId)).get();
|
|
317
|
-
if (machine) {
|
|
318
|
-
result.target_ip = machine.ipAddress;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
return result;
|
|
375
|
+
return loadHookConfigMap(moduleId, db);
|
|
324
376
|
}
|
|
325
377
|
|
|
326
378
|
/**
|
|
@@ -490,9 +542,17 @@ function buildRouteOps(db: DbClient): RouteOps {
|
|
|
490
542
|
return db.select().from(webRoutes).all();
|
|
491
543
|
},
|
|
492
544
|
upsertRoute(route) {
|
|
493
|
-
//
|
|
545
|
+
// Path uniqueness is scoped to (hostname, path). Same module +
|
|
546
|
+
// same (hostname, path) is treated as an upsert, so delete first
|
|
547
|
+
// to keep the operation atomic in the eyes of the unique index.
|
|
494
548
|
db.delete(webRoutes)
|
|
495
|
-
.where(
|
|
549
|
+
.where(
|
|
550
|
+
and(
|
|
551
|
+
eq(webRoutes.hostname, route.hostname),
|
|
552
|
+
eq(webRoutes.path, route.path),
|
|
553
|
+
eq(webRoutes.moduleId, route.moduleId),
|
|
554
|
+
),
|
|
555
|
+
)
|
|
496
556
|
.run();
|
|
497
557
|
db.insert(webRoutes)
|
|
498
558
|
.values({
|
|
@@ -500,9 +560,9 @@ function buildRouteOps(db: DbClient): RouteOps {
|
|
|
500
560
|
moduleId: route.moduleId,
|
|
501
561
|
type: route.type,
|
|
502
562
|
path: route.path,
|
|
563
|
+
hostname: route.hostname,
|
|
503
564
|
targetHost: route.targetHost ?? null,
|
|
504
565
|
targetPort: route.targetPort ?? null,
|
|
505
|
-
subdomain: route.subdomain ?? null,
|
|
506
566
|
websocket: route.websocket ?? false,
|
|
507
567
|
contentHash: route.contentHash ?? null,
|
|
508
568
|
})
|
|
@@ -55,6 +55,7 @@ function makeContext(overrides: Partial<HookContext> = {}): HookContext {
|
|
|
55
55
|
// Minimal fakes for the capability interfaces — used in both consumer and
|
|
56
56
|
// provider tests below.
|
|
57
57
|
const fakePublicWeb: PublicWebCapability = {
|
|
58
|
+
defaultHostname: 'www.example.com',
|
|
58
59
|
async publishStaticSite() {
|
|
59
60
|
return { success: true, path: '/test', filesUploaded: 0, contentHash: 'fake' };
|
|
60
61
|
},
|
|
@@ -70,6 +71,9 @@ const fakePublicWeb: PublicWebCapability = {
|
|
|
70
71
|
async unregister_routes() {
|
|
71
72
|
return undefined;
|
|
72
73
|
},
|
|
74
|
+
async getServerIp() {
|
|
75
|
+
return '10.0.10.10';
|
|
76
|
+
},
|
|
73
77
|
};
|
|
74
78
|
|
|
75
79
|
const fakeIdp: IdpCapability = {
|
|
@@ -261,6 +265,7 @@ describe('defineCapabilityFunction', () => {
|
|
|
261
265
|
const factory = defineCapabilityFunction({
|
|
262
266
|
capability: 'public_web',
|
|
263
267
|
handler: () => ({
|
|
268
|
+
defaultHostname: 'www.example.com',
|
|
264
269
|
async publishStaticSite() {
|
|
265
270
|
return { success: true, path: '/x', filesUploaded: 0, contentHash: 'x' };
|
|
266
271
|
},
|
|
@@ -276,6 +281,9 @@ describe('defineCapabilityFunction', () => {
|
|
|
276
281
|
async unregister_routes() {
|
|
277
282
|
return undefined;
|
|
278
283
|
},
|
|
284
|
+
async getServerIp() {
|
|
285
|
+
return '10.0.10.10';
|
|
286
|
+
},
|
|
279
287
|
}),
|
|
280
288
|
});
|
|
281
289
|
|
|
@@ -340,7 +348,7 @@ describe('defineCapabilityFunction', () => {
|
|
|
340
348
|
logger: makeLogger(),
|
|
341
349
|
});
|
|
342
350
|
|
|
343
|
-
const result = await methods.registerHost({
|
|
351
|
+
const result = await methods.registerHost({ fqdn: 'www.example.com', ip: '1.2.3.4' });
|
|
344
352
|
expect(result.success).toBe(true);
|
|
345
353
|
expect(result.outputs.registered_ip).toBe('1.2.3.4');
|
|
346
354
|
});
|
package/src/hooks/executor.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
|
14
14
|
import { join, resolve } from 'node:path';
|
|
15
|
-
import { isCompiledHook } from '@celilo/capabilities';
|
|
15
|
+
import { isCompiledHook, isMissingProviderInputError } from '@celilo/capabilities';
|
|
16
16
|
import {
|
|
17
17
|
type ContractHookSignature,
|
|
18
18
|
resolveContract,
|
|
@@ -458,6 +458,27 @@ export async function invokeHook(
|
|
|
458
458
|
logger.info(`Screenshot saved: ${screenshotPath}`);
|
|
459
459
|
}
|
|
460
460
|
|
|
461
|
+
// Recognise the cross-module structured error so the orchestrator can
|
|
462
|
+
// run the ensure interview and retry the hook. The check goes through
|
|
463
|
+
// a duck-typed guard because @celilo/capabilities can be loaded from
|
|
464
|
+
// either the workspace package or a module's bundled copy, and
|
|
465
|
+
// `instanceof` fails across those identity boundaries.
|
|
466
|
+
if (isMissingProviderInputError(error)) {
|
|
467
|
+
return {
|
|
468
|
+
success: false,
|
|
469
|
+
outputs: {},
|
|
470
|
+
error: message,
|
|
471
|
+
screenshotPath,
|
|
472
|
+
duration: Date.now() - startTime,
|
|
473
|
+
missingProviderInput: {
|
|
474
|
+
providerModuleId: error.providerModuleId,
|
|
475
|
+
ensureId: error.ensureId,
|
|
476
|
+
value: error.value,
|
|
477
|
+
humanContext: error.humanContext,
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
461
482
|
return {
|
|
462
483
|
success: false,
|
|
463
484
|
outputs: {},
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `loadHookConfigMap`.
|
|
3
|
+
*
|
|
4
|
+
* The helper centralises the "build the config map a hook script will
|
|
5
|
+
* see" logic across `capability-loader`, `run-named-hook`, and
|
|
6
|
+
* `health-runner`. The behaviour under test:
|
|
7
|
+
*
|
|
8
|
+
* 1. `module_configs` rows are loaded as-is, parsed via `valueJson`
|
|
9
|
+
* when present.
|
|
10
|
+
* 2. If `target_ip` is already in the rows, the machine fallback is
|
|
11
|
+
* skipped entirely (deploy already wrote it).
|
|
12
|
+
* 3. If `target_ip` isn't set AND the module has a `module_infrastructure`
|
|
13
|
+
* row pointing at a machine, fill BOTH `target_ip` and `ip.primary`
|
|
14
|
+
* from `machines.ipAddress`.
|
|
15
|
+
* 4. If neither is true (no infra row, or infra has no machineId,
|
|
16
|
+
* or the machine record is gone), neither key gets filled.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
20
|
+
import type { DbClient } from '../db/client';
|
|
21
|
+
import { machines, moduleConfigs, moduleInfrastructure, modules } from '../db/schema';
|
|
22
|
+
import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
|
|
23
|
+
import { loadHookConfigMap } from './load-hook-config';
|
|
24
|
+
|
|
25
|
+
describe('loadHookConfigMap', () => {
|
|
26
|
+
let db: DbClient;
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
db = await setupTestDatabase();
|
|
30
|
+
db.insert(modules)
|
|
31
|
+
.values({
|
|
32
|
+
id: 'mod',
|
|
33
|
+
name: 'test',
|
|
34
|
+
version: '1.0.0',
|
|
35
|
+
sourcePath: '/tmp/x',
|
|
36
|
+
manifestData: {},
|
|
37
|
+
})
|
|
38
|
+
.run();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
await cleanupTestDatabase(db);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('returns empty config map when no rows + no infrastructure', async () => {
|
|
46
|
+
const result = await loadHookConfigMap('mod', db);
|
|
47
|
+
expect(result).toEqual({});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('returns scalar string config values from module_configs', async () => {
|
|
51
|
+
db.insert(moduleConfigs)
|
|
52
|
+
.values([
|
|
53
|
+
{ moduleId: 'mod', key: 'hostname', value: 'caddy', valueJson: null },
|
|
54
|
+
{ moduleId: 'mod', key: 'acme_email', value: 'admin@example.com', valueJson: null },
|
|
55
|
+
])
|
|
56
|
+
.run();
|
|
57
|
+
|
|
58
|
+
const result = await loadHookConfigMap('mod', db);
|
|
59
|
+
expect(result).toEqual({ hostname: 'caddy', acme_email: 'admin@example.com' });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('parses valueJson for complex types (arrays, objects)', async () => {
|
|
63
|
+
db.insert(moduleConfigs)
|
|
64
|
+
.values([
|
|
65
|
+
{
|
|
66
|
+
moduleId: 'mod',
|
|
67
|
+
key: 'hostnames',
|
|
68
|
+
value: '',
|
|
69
|
+
valueJson: '["www.example.com","example.com"]',
|
|
70
|
+
},
|
|
71
|
+
{ moduleId: 'mod', key: 'plain', value: 'string-val', valueJson: null },
|
|
72
|
+
])
|
|
73
|
+
.run();
|
|
74
|
+
|
|
75
|
+
const result = await loadHookConfigMap('mod', db);
|
|
76
|
+
expect(result.hostnames).toEqual(['www.example.com', 'example.com']);
|
|
77
|
+
expect(result.plain).toBe('string-val');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('preserves an explicit target_ip in module_configs (skips machine fallback)', async () => {
|
|
81
|
+
db.insert(moduleConfigs)
|
|
82
|
+
.values({ moduleId: 'mod', key: 'target_ip', value: '10.0.10.10', valueJson: null })
|
|
83
|
+
.run();
|
|
84
|
+
// A machine record exists but should NOT override the explicit value.
|
|
85
|
+
db.insert(machines)
|
|
86
|
+
.values({
|
|
87
|
+
id: 'machine-1',
|
|
88
|
+
hostname: 'm1',
|
|
89
|
+
zone: 'dmz',
|
|
90
|
+
ipAddress: '192.168.0.99',
|
|
91
|
+
sshUser: 'root',
|
|
92
|
+
sshKeyEncrypted: 'x',
|
|
93
|
+
hardware: { cpu_cores: 1, memory_mb: 512, disk_gb: 10 },
|
|
94
|
+
})
|
|
95
|
+
.run();
|
|
96
|
+
db.insert(moduleInfrastructure)
|
|
97
|
+
.values({
|
|
98
|
+
id: `infra-${Math.random().toString(36).slice(2)}`,
|
|
99
|
+
moduleId: 'mod',
|
|
100
|
+
infrastructureType: 'machine',
|
|
101
|
+
machineId: 'machine-1',
|
|
102
|
+
})
|
|
103
|
+
.run();
|
|
104
|
+
|
|
105
|
+
const result = await loadHookConfigMap('mod', db);
|
|
106
|
+
expect(result.target_ip).toBe('10.0.10.10');
|
|
107
|
+
// ip.primary is only filled by the fallback path; explicit
|
|
108
|
+
// target_ip means the helper short-circuits and never looks at the
|
|
109
|
+
// machine.
|
|
110
|
+
expect(result['ip.primary']).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('fills BOTH target_ip and ip.primary from machine.ipAddress when target_ip is unset', async () => {
|
|
114
|
+
db.insert(machines)
|
|
115
|
+
.values({
|
|
116
|
+
id: 'machine-1',
|
|
117
|
+
hostname: 'caddy-host',
|
|
118
|
+
zone: 'dmz',
|
|
119
|
+
ipAddress: '10.0.10.10',
|
|
120
|
+
sshUser: 'root',
|
|
121
|
+
sshKeyEncrypted: 'x',
|
|
122
|
+
hardware: { cpu_cores: 1, memory_mb: 512, disk_gb: 10 },
|
|
123
|
+
})
|
|
124
|
+
.run();
|
|
125
|
+
db.insert(moduleInfrastructure)
|
|
126
|
+
.values({
|
|
127
|
+
id: `infra-${Math.random().toString(36).slice(2)}`,
|
|
128
|
+
moduleId: 'mod',
|
|
129
|
+
infrastructureType: 'machine',
|
|
130
|
+
machineId: 'machine-1',
|
|
131
|
+
})
|
|
132
|
+
.run();
|
|
133
|
+
|
|
134
|
+
const result = await loadHookConfigMap('mod', db);
|
|
135
|
+
expect(result.target_ip).toBe('10.0.10.10');
|
|
136
|
+
expect(result['ip.primary']).toBe('10.0.10.10');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('leaves target_ip unset when there is no infrastructure record', async () => {
|
|
140
|
+
db.insert(moduleConfigs)
|
|
141
|
+
.values({ moduleId: 'mod', key: 'hostname', value: 'caddy', valueJson: null })
|
|
142
|
+
.run();
|
|
143
|
+
// no moduleInfrastructure row inserted
|
|
144
|
+
|
|
145
|
+
const result = await loadHookConfigMap('mod', db);
|
|
146
|
+
expect(result.hostname).toBe('caddy');
|
|
147
|
+
expect(result.target_ip).toBeUndefined();
|
|
148
|
+
expect(result['ip.primary']).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('leaves target_ip unset when infrastructure row has no machineId', async () => {
|
|
152
|
+
db.insert(moduleInfrastructure)
|
|
153
|
+
.values({
|
|
154
|
+
id: `infra-${Math.random().toString(36).slice(2)}`,
|
|
155
|
+
moduleId: 'mod',
|
|
156
|
+
infrastructureType: 'container_service',
|
|
157
|
+
machineId: null,
|
|
158
|
+
})
|
|
159
|
+
.run();
|
|
160
|
+
|
|
161
|
+
const result = await loadHookConfigMap('mod', db);
|
|
162
|
+
expect(result.target_ip).toBeUndefined();
|
|
163
|
+
expect(result['ip.primary']).toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the canonical config map a hook script sees.
|
|
3
|
+
*
|
|
4
|
+
* Every code path that runs a hook (deploy / health_check / on_uninstall /
|
|
5
|
+
* `module run-hook`) needs to assemble the same config shape; without a
|
|
6
|
+
* single source of truth the implementations drift and hooks see
|
|
7
|
+
* subtly-different state depending on which command invoked them. The
|
|
8
|
+
* concrete bug that motivated this helper: caddy's `on_uninstall` hook
|
|
9
|
+
* skipped its DNAT cleanup because `runNamedHook` (a recently-extracted
|
|
10
|
+
* helper) didn't apply the `target_ip` fallback that
|
|
11
|
+
* `capability-loader.ts:loadModuleConfig` had — same shape, different
|
|
12
|
+
* code, easy to miss.
|
|
13
|
+
*
|
|
14
|
+
* The shape:
|
|
15
|
+
* - Every row from `module_configs`, parsed from `valueJson` when set.
|
|
16
|
+
* - If `target_ip` isn't in the row set, look up the deployment
|
|
17
|
+
* machine and inject both `target_ip` AND `ip.primary` from
|
|
18
|
+
* `machines.ipAddress`. Two keys because consumers historically used
|
|
19
|
+
* either name (e.g. caddy's `setup-network.ts` checks
|
|
20
|
+
* `target_ip || ip.primary`); fixing the drift means filling both.
|
|
21
|
+
*
|
|
22
|
+
* Container deploys write `target_ip` into `module_configs` explicitly
|
|
23
|
+
* during generate/deploy, so the fallback only fires for machine
|
|
24
|
+
* deploys (existing iron, no terraform — what every e2e test uses).
|
|
25
|
+
*/
|
|
26
|
+
import { eq } from 'drizzle-orm';
|
|
27
|
+
import type { DbClient } from '../db/client';
|
|
28
|
+
import { machines, moduleConfigs, moduleInfrastructure } from '../db/schema';
|
|
29
|
+
|
|
30
|
+
export async function loadHookConfigMap(
|
|
31
|
+
moduleId: string,
|
|
32
|
+
db: DbClient,
|
|
33
|
+
): Promise<Record<string, unknown>> {
|
|
34
|
+
const configRecords = db
|
|
35
|
+
.select()
|
|
36
|
+
.from(moduleConfigs)
|
|
37
|
+
.where(eq(moduleConfigs.moduleId, moduleId))
|
|
38
|
+
.all();
|
|
39
|
+
|
|
40
|
+
const configMap: Record<string, unknown> = {};
|
|
41
|
+
for (const c of configRecords) {
|
|
42
|
+
configMap[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (configMap.target_ip) return configMap;
|
|
46
|
+
|
|
47
|
+
const infra = db
|
|
48
|
+
.select()
|
|
49
|
+
.from(moduleInfrastructure)
|
|
50
|
+
.where(eq(moduleInfrastructure.moduleId, moduleId))
|
|
51
|
+
.get();
|
|
52
|
+
if (!infra?.machineId) return configMap;
|
|
53
|
+
|
|
54
|
+
const machine = db.select().from(machines).where(eq(machines.id, infra.machineId)).get();
|
|
55
|
+
if (!machine) return configMap;
|
|
56
|
+
|
|
57
|
+
configMap.target_ip = machine.ipAddress;
|
|
58
|
+
configMap['ip.primary'] = machine.ipAddress;
|
|
59
|
+
return configMap;
|
|
60
|
+
}
|
package/src/hooks/logger.ts
CHANGED
|
@@ -6,8 +6,15 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { FuelGauge } from '../cli/fuel-gauge';
|
|
9
|
+
import { getActiveDisplay } from '../cli/prompts';
|
|
9
10
|
import type { HookLogger } from './types';
|
|
10
11
|
|
|
12
|
+
const DIM = '\x1b[2m';
|
|
13
|
+
const RESET = '\x1b[0m';
|
|
14
|
+
const GREEN = '\x1b[32m';
|
|
15
|
+
const YELLOW = '\x1b[33m';
|
|
16
|
+
const RED = '\x1b[31m';
|
|
17
|
+
|
|
11
18
|
/**
|
|
12
19
|
* Create a hook logger that outputs to a FuelGauge progress indicator
|
|
13
20
|
*
|
|
@@ -22,20 +29,43 @@ export function createGaugeLogger(
|
|
|
22
29
|
hookName: string,
|
|
23
30
|
): HookLogger {
|
|
24
31
|
const prefix = `[${moduleId}:${hookName}]`;
|
|
32
|
+
const nonInteractive = !process.stdout.isTTY;
|
|
33
|
+
|
|
34
|
+
// When a ProgressDisplay is active, the running step header
|
|
35
|
+
// (e.g. "… celilo-website: on_install") already names the hook,
|
|
36
|
+
// so the per-line "[moduleId:hookName]" prefix becomes noise.
|
|
37
|
+
// Dim the message and skip the stdout double-write — display
|
|
38
|
+
// owns the output channel and forwards via gauge.addOutput →
|
|
39
|
+
// display.subEvent. When no display is active, fall back to
|
|
40
|
+
// the prefixed gauge output.
|
|
41
|
+
function emit(level: 'info' | 'warn' | 'error' | 'success', message: string) {
|
|
42
|
+
if (getActiveDisplay()) {
|
|
43
|
+
const icon =
|
|
44
|
+
level === 'warn'
|
|
45
|
+
? `${YELLOW}⚠${RESET} `
|
|
46
|
+
: level === 'error'
|
|
47
|
+
? `${RED}✗${RESET} `
|
|
48
|
+
: level === 'success'
|
|
49
|
+
? `${GREEN}✓${RESET} `
|
|
50
|
+
: '';
|
|
51
|
+
gauge.addOutput(`${icon}${DIM}${message}${RESET}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const icon =
|
|
56
|
+
level === 'warn' ? '⚠ ' : level === 'error' ? '✗ ' : level === 'success' ? '✓ ' : '';
|
|
57
|
+
const line = `${prefix} ${icon}${message}`;
|
|
58
|
+
gauge.addOutput(line);
|
|
59
|
+
if (nonInteractive) {
|
|
60
|
+
process.stdout.write(`${line}\n`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
25
63
|
|
|
26
64
|
return {
|
|
27
|
-
info(message: string)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
gauge.addOutput(`${prefix} ⚠ ${message}`);
|
|
32
|
-
},
|
|
33
|
-
error(message: string) {
|
|
34
|
-
gauge.addOutput(`${prefix} ✗ ${message}`);
|
|
35
|
-
},
|
|
36
|
-
success(message: string) {
|
|
37
|
-
gauge.addOutput(`${prefix} ✓ ${message}`);
|
|
38
|
-
},
|
|
65
|
+
info: (message: string) => emit('info', message),
|
|
66
|
+
warn: (message: string) => emit('warn', message),
|
|
67
|
+
error: (message: string) => emit('error', message),
|
|
68
|
+
success: (message: string) => emit('success', message),
|
|
39
69
|
};
|
|
40
70
|
}
|
|
41
71
|
|