@celilo/cli 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/drizzle/0031_module_config_source.sql +20 -0
  2. package/drizzle/meta/_journal.json +8 -1
  3. package/package.json +3 -3
  4. package/schemas/system_config.json +7 -1
  5. package/src/ansible/inventory.test.ts +2 -1
  6. package/src/api/sessions.test.ts +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/backup-rename.test.ts +2 -1
  9. package/src/cli/cli.test.ts +2 -1
  10. package/src/cli/commands/console-get-chain.test.ts +2 -1
  11. package/src/cli/commands/firewall-interface-list.test.ts +158 -8
  12. package/src/cli/commands/firewall-interface-list.ts +73 -7
  13. package/src/cli/commands/machine-add.ts +12 -55
  14. package/src/cli/commands/module-config.test.ts +22 -2
  15. package/src/cli/commands/module-deploy.ts +8 -2
  16. package/src/cli/commands/module-generate.test.ts +53 -0
  17. package/src/cli/commands/module-generate.ts +31 -26
  18. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  19. package/src/cli/commands/module-import-registry.test.ts +2 -1
  20. package/src/cli/commands/module-import.ts +1 -1
  21. package/src/cli/commands/module-operations.test.ts +2 -1
  22. package/src/cli/commands/module-publish.test.ts +5 -12
  23. package/src/cli/commands/module-update.test.ts +87 -4
  24. package/src/cli/commands/module-update.ts +14 -4
  25. package/src/cli/commands/module-upgrade.test.ts +15 -0
  26. package/src/cli/commands/module-upgrade.ts +54 -2
  27. package/src/cli/commands/module-verify.test.ts +2 -3
  28. package/src/cli/commands/module-verify.ts +0 -1
  29. package/src/cli/commands/monitor.ts +2 -10
  30. package/src/cli/commands/notify-config.test.ts +5 -3
  31. package/src/cli/commands/registry-owner.test.ts +2 -1
  32. package/src/cli/commands/registry-token.test.ts +2 -1
  33. package/src/cli/commands/restore.ts +16 -6
  34. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  35. package/src/cli/commands/system-config.test.ts +148 -0
  36. package/src/cli/commands/system-config.ts +26 -1
  37. package/src/cli/commands/system-doctor.test.ts +71 -0
  38. package/src/cli/commands/system-doctor.ts +110 -24
  39. package/src/cli/commands/system-init-deprecation.test.ts +6 -3
  40. package/src/cli/commands/system-migrate.test.ts +2 -1
  41. package/src/cli/generate-zsh-completion.ts +1 -1
  42. package/src/cli/index.ts +6 -4
  43. package/src/cli/restore-command.test.ts +2 -1
  44. package/src/cli/restore-migration-failure.test.ts +160 -0
  45. package/src/config/paths.test.ts +3 -3
  46. package/src/db/client.ts +5 -0
  47. package/src/db/migrate.test.ts +62 -135
  48. package/src/db/migrate.ts +17 -12
  49. package/src/db/schema.ts +10 -0
  50. package/src/hooks/broker.test.ts +106 -2
  51. package/src/hooks/broker.ts +91 -1
  52. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  53. package/src/hooks/capability-loader.test.ts +78 -0
  54. package/src/hooks/capability-loader.ts +52 -3
  55. package/src/hooks/define-hook.test.ts +4 -3
  56. package/src/hooks/executor.test.ts +106 -19
  57. package/src/hooks/executor.ts +120 -13
  58. package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
  59. package/src/hooks/hook-jail-unreachability.test.ts +4 -3
  60. package/src/hooks/hook-protocol.ts +46 -1
  61. package/src/hooks/hook-runner.ts +36 -0
  62. package/src/hooks/hook-store-proxy.test.ts +109 -0
  63. package/src/hooks/hook-store-proxy.ts +85 -0
  64. package/src/hooks/hook-store.test.ts +168 -0
  65. package/src/hooks/hook-store.ts +290 -0
  66. package/src/hooks/hook-timeout.test.ts +3 -2
  67. package/src/hooks/hook-trespass.test.ts +39 -5
  68. package/src/hooks/jail.test.ts +62 -3
  69. package/src/hooks/jail.ts +67 -8
  70. package/src/hooks/mount-set.test.ts +208 -0
  71. package/src/hooks/mount-set.ts +62 -14
  72. package/src/hooks/run-named-hook.ts +2 -0
  73. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  74. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  75. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  76. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  77. package/src/hooks/unjailed-lint.test.ts +22 -6
  78. package/src/manifest/schema.ts +1 -0
  79. package/src/module/packaging/audit.ts +9 -26
  80. package/src/module/packaging/build-paths.test.ts +127 -0
  81. package/src/module/packaging/build-paths.ts +175 -0
  82. package/src/module/packaging/build.test.ts +71 -1
  83. package/src/module/packaging/build.ts +130 -2
  84. package/src/module/packaging/extract.ts +1 -5
  85. package/src/module/web-root.ts +17 -1
  86. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  87. package/src/policy/module-script-scan.test.ts +42 -1
  88. package/src/policy/module-script-scan.ts +235 -5
  89. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  90. package/src/policy/no-swallowed-refusal.test.ts +264 -0
  91. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  92. package/src/registry/client.test.ts +149 -0
  93. package/src/registry/client.ts +203 -11
  94. package/src/services/alerting/ack.test.ts +2 -1
  95. package/src/services/alerting/cadence-migration.test.ts +3 -2
  96. package/src/services/alerting/coverage-source.test.ts +2 -1
  97. package/src/services/alerting/deferral.test.ts +2 -1
  98. package/src/services/alerting/delivery-loop.test.ts +2 -1
  99. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  100. package/src/services/alerting/format.test.ts +57 -0
  101. package/src/services/alerting/format.ts +24 -0
  102. package/src/services/alerting/inbound-poller.test.ts +2 -1
  103. package/src/services/alerting/inbound.test.ts +2 -1
  104. package/src/services/alerting/notification-responder.test.ts +2 -1
  105. package/src/services/alerting/run-monitor.test.ts +2 -1
  106. package/src/services/alerting/run-monitor.ts +2 -2
  107. package/src/services/alerting/store.test.ts +2 -1
  108. package/src/services/alerting/sweep-runner.test.ts +2 -1
  109. package/src/services/alerting/tokens.test.ts +2 -1
  110. package/src/services/aspect-approvals.test.ts +2 -1
  111. package/src/services/aspect-reconcile.test.ts +4 -3
  112. package/src/services/aspect-runner.test.ts +2 -1
  113. package/src/services/audit/module-integrity.test.ts +0 -21
  114. package/src/services/audit/module-integrity.ts +0 -14
  115. package/src/services/backup-age-agreement.test.ts +2 -1
  116. package/src/services/backup-create.ts +7 -7
  117. package/src/services/backup-envelope-roundtrip.test.ts +47 -3
  118. package/src/services/backup-in-flight-refusal.test.ts +2 -1
  119. package/src/services/backup-restore.ts +8 -4
  120. package/src/services/bus-ensure-flow.test.ts +2 -1
  121. package/src/services/bus-interview-park.test.ts +2 -1
  122. package/src/services/bus-interview.ts +37 -14
  123. package/src/services/bus-secret-flow.test.ts +2 -1
  124. package/src/services/capability-table-rows.test.ts +2 -1
  125. package/src/services/config-provenance.ts +4 -0
  126. package/src/services/consumer-cleanup.test.ts +3 -2
  127. package/src/services/container-service.test.ts +2 -1
  128. package/src/services/control-plane-bootstrap.test.ts +123 -2
  129. package/src/services/control-plane-bootstrap.ts +51 -4
  130. package/src/services/cross-module-read.test.ts +2 -1
  131. package/src/services/deploy-preflight.ts +8 -2
  132. package/src/services/deploy-validation.test.ts +25 -2
  133. package/src/services/deploy-validation.ts +8 -0
  134. package/src/services/dns-discovery.test.ts +54 -0
  135. package/src/services/dns-discovery.ts +47 -5
  136. package/src/services/dns-internal-records.test.ts +3 -2
  137. package/src/services/dns-provider-backfill.test.ts +2 -1
  138. package/src/services/dns-registrations.test.ts +2 -1
  139. package/src/services/ensure-interview.test.ts +3 -2
  140. package/src/services/fleet-checks.test.ts +3 -2
  141. package/src/services/fleet-key.test.ts +68 -3
  142. package/src/services/fleet-key.ts +54 -0
  143. package/src/services/health-runner.ts +2 -0
  144. package/src/services/infrastructure-selector.test.ts +2 -1
  145. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  146. package/src/services/machine-pool.test.ts +2 -1
  147. package/src/services/module-config.test.ts +2 -1
  148. package/src/services/module-config.ts +20 -2
  149. package/src/services/module-deploy.dns-repoint.test.ts +188 -0
  150. package/src/services/module-deploy.ts +199 -21
  151. package/src/services/module-operations.test.ts +2 -1
  152. package/src/services/module-subscriptions.test.ts +2 -1
  153. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  154. package/src/services/module-validator/git-hygiene.ts +83 -14
  155. package/src/services/port-forwards.test.ts +2 -1
  156. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  157. package/src/services/proxmox-reconcile.test.ts +2 -1
  158. package/src/services/restore-from-file.test.ts +23 -2
  159. package/src/services/restore-from-file.ts +21 -6
  160. package/src/services/restore-preflight.test.ts +2 -1
  161. package/src/services/secret-schema-loader.test.ts +2 -1
  162. package/src/services/ssh-key-manager.test.ts +2 -1
  163. package/src/services/static-content-converge.test.ts +144 -5
  164. package/src/services/static-content-converge.ts +87 -17
  165. package/src/services/system-config-schema-types.ts +1 -1
  166. package/src/services/system-config-validator.test.ts +36 -0
  167. package/src/services/system-config-validator.ts +11 -0
  168. package/src/services/system-state-stage.test.ts +2 -1
  169. package/src/services/trusted-sources.test.ts +33 -2
  170. package/src/services/trusted-sources.ts +47 -10
  171. package/src/services/zone-detector.test.ts +2 -1
  172. package/src/templates/generator.ts +9 -2
  173. package/src/test-utils/bus-responder.ts +5 -3
  174. package/src/test-utils/db-path.ts +25 -0
  175. package/src/test-utils/integration.ts +7 -0
  176. package/src/test-utils/module-fixtures.ts +5 -6
  177. package/src/variables/context.ts +16 -5
  178. package/src/module/packaging/generated-plane.test.ts +0 -79
  179. package/src/module/packaging/generated-plane.ts +0 -134
@@ -4,6 +4,7 @@
4
4
  * Coordinates full module deployment workflow across all phases
5
5
  */
6
6
 
7
+ import { rmSync } from 'node:fs';
7
8
  import { join } from 'node:path';
8
9
  import { ProgressDisplay } from '@celilo/cli-display';
9
10
  import { and, eq } from 'drizzle-orm';
@@ -11,9 +12,10 @@ import { generateInventory } from '../ansible/inventory';
11
12
  import { FuelGauge } from '../cli/fuel-gauge';
12
13
  import { log, setActiveDisplay } from '../cli/prompts';
13
14
  import type { DbClient } from '../db/client';
14
- import { capabilities, machines, modules } from '../db/schema';
15
+ import { capabilities, machines, modules, systemConfig } from '../db/schema';
15
16
  import { loadCapabilityFunctions } from '../hooks/capability-loader';
16
17
  import { invokeHook } from '../hooks/executor';
18
+ import { createHookStores } from '../hooks/hook-store';
17
19
  import { createGaugeLogger } from '../hooks/logger';
18
20
  import { describeArtifacts } from '../hooks/types';
19
21
  import type { HookDefinition, HookLogger, HookResult } from '../hooks/types';
@@ -22,6 +24,7 @@ import { decryptSecret } from '../secrets/encryption';
22
24
  import { getOrCreateMasterKey } from '../secrets/master-key';
23
25
  import { buildResolutionContext } from '../variables/context';
24
26
  import { maybeRunAspectForTrigger } from './aspect-runner';
27
+ import { askConfirm } from './bus-interview';
25
28
  import {
26
29
  autoDeriveMachineConfig,
27
30
  findEnsureOnProvider,
@@ -40,6 +43,7 @@ import { CONTROL_PLANE_MODULE_ID } from './deployed-systems';
40
43
  import { getModuleSystems } from './deployed-systems';
41
44
  import { E2E_CONFLICT_MESSAGE, runningE2eContainers } from './e2e-guard';
42
45
  import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
46
+ import { InterviewAbandonedError, InterviewUnansweredError } from './interview-errors';
43
47
  import { findMachineForModule } from './machine-pool';
44
48
  import { ensureRequiredNetworks } from './network-ensure';
45
49
  import {
@@ -101,6 +105,17 @@ export interface DeployOptions {
101
105
  * this mode — that requires a real hook run.
102
106
  */
103
107
  stopAfterInterview?: boolean;
108
+ /**
109
+ * D4 (control-plane-stops-building-modules): retain the generated project
110
+ * after a successful deploy instead of deleting it. The tree is ephemeral
111
+ * by design — rendered for this deploy from the signed payload plus live
112
+ * config, used, then removed — because a persisted generated tree cannot be
113
+ * validated (only its verbatim role assets have a meaningful digest, the
114
+ * rest is rendered and SUPPOSED to differ), it holds rendered secrets, and
115
+ * it is what the next deploy would ship. This flag keeps it for deliberate
116
+ * inspection. It is always kept when the deploy fails.
117
+ */
118
+ keepGeneratedProject?: boolean;
104
119
  }
105
120
 
106
121
  /**
@@ -365,6 +380,26 @@ export async function deployModule(
365
380
  closeDeployWindows(db, moduleId, new Date());
366
381
 
367
382
  if (result.success) {
383
+ // D4 (control-plane-stops-building-modules): the generated project is
384
+ // ephemeral. It was rendered for THIS deploy from the signed payload plus
385
+ // live config; a persisted copy is a tree nothing can validate, it holds
386
+ // rendered secrets, and it is what a later deploy would ship. Now that
387
+ // the deploy that needed it is done — ansible, hooks, DNS backfill and
388
+ // aspects all ran inside the impl — it goes. Kept on failure (the impl's
389
+ // early returns) and under --keep.
390
+ if (!options.keepGeneratedProject) {
391
+ const deployed = db.select().from(modules).where(eq(modules.id, moduleId)).get();
392
+ if (deployed) {
393
+ const generatedPath = `${deployed.sourcePath}/generated`;
394
+ try {
395
+ rmSync(generatedPath, { recursive: true, force: true });
396
+ log.info(`Removed generated/ for '${moduleId}' (ephemeral by design; --keep retains it)`);
397
+ } catch (cleanupError) {
398
+ const msg = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
399
+ log.warn(`Failed to remove generated/ for '${moduleId}': ${msg}`);
400
+ }
401
+ }
402
+ }
368
403
  // A module that declares a suggested interval starts being watched on its
369
404
  // first successful deploy — watching is the default, not a thing to
370
405
  // remember.
@@ -388,6 +423,154 @@ export async function deployModule(
388
423
  return result;
389
424
  }
390
425
 
426
+ /**
427
+ * Headless bound for the repoint confirm. A staged responder replies in
428
+ * milliseconds; this exists so an attached-but-unstaged responder produces a
429
+ * defined skip instead of hanging until the suite budget kills the deploy
430
+ * (module-orchestrator-primitives design.md D5, "never a hang"). On a TTY
431
+ * there is no bound: the operator IS the responder and may take minutes.
432
+ */
433
+ const DNS_REPOINT_HEADLESS_TIMEOUT_MS = 60_000;
434
+
435
+ /**
436
+ * The deploy-time DNS repoint for a `dns_internal` provider
437
+ * (module-orchestrator-primitives slice 3, design D5). celilo owns the whole
438
+ * operation because every input is already celilo's: the resolver address is
439
+ * the system celilo just placed, and the prior primary/fallback are celilo's
440
+ * own rows. knot's on_install used to do this through the CLI, one key at a
441
+ * time, under a catch that reported success over a repoint that never
442
+ * happened.
443
+ *
444
+ * D5 is a recorded DELIBERATE EXCEPTION to the framework-does-not-infer-intent
445
+ * rule. What makes it acceptable is the interview: the deploy ASKS before it
446
+ * touches the fleet resolver, and a declined repoint is an operator decision,
447
+ * not an error. Do not cite this as precedent (design.md D5, "So do not cite
448
+ * this as precedent").
449
+ */
450
+ export interface DnsRepointPlan {
451
+ serverIp: string;
452
+ priorPrimary: string | undefined;
453
+ existingFallback: string | undefined;
454
+ /** Merged dns.fallback when the repoint demotes the prior primary; undefined leaves the key untouched. */
455
+ newFallback: string | undefined;
456
+ /** False when dns.primary already names this resolver: the deploy asks nothing. */
457
+ needsRepoint: boolean;
458
+ }
459
+
460
+ /**
461
+ * Pure half of the repoint. The demote keeps an existing fallback (D5's
462
+ * secondary observation: knot's one-key-at-a-time hook overwrote it, dropping
463
+ * e.g. 8.8.8.8 from the fleet's resolver list).
464
+ */
465
+ export function planDnsRepoint(args: {
466
+ serverIp: string;
467
+ priorPrimary: string | undefined;
468
+ existingFallback: string | undefined;
469
+ }): DnsRepointPlan {
470
+ const { serverIp, priorPrimary, existingFallback } = args;
471
+ if (priorPrimary === serverIp) {
472
+ return {
473
+ serverIp,
474
+ priorPrimary,
475
+ existingFallback,
476
+ newFallback: undefined,
477
+ needsRepoint: false,
478
+ };
479
+ }
480
+ const newFallback = priorPrimary ? mergeFallback(existingFallback, priorPrimary) : undefined;
481
+ return { serverIp, priorPrimary, existingFallback, newFallback, needsRepoint: true };
482
+ }
483
+
484
+ /** Comma-separated, demoted primary first, deduped (public-dns-probe.ts parses commas). */
485
+ function mergeFallback(existing: string | undefined, demoted: string): string {
486
+ const entries = [demoted];
487
+ for (const part of (existing ?? '').split(',')) {
488
+ const ip = part.trim();
489
+ if (ip && !entries.includes(ip)) entries.push(ip);
490
+ }
491
+ return entries.join(',');
492
+ }
493
+
494
+ function readSystemConfigValue(db: DbClient, key: string): string | undefined {
495
+ const row = db.select().from(systemConfig).where(eq(systemConfig.key, key)).get();
496
+ return row?.value && row.value.length > 0 ? row.value : undefined;
497
+ }
498
+
499
+ function writeSystemConfigValue(db: DbClient, key: string, value: string): void {
500
+ db.insert(systemConfig)
501
+ .values({ key, value })
502
+ .onConflictDoUpdate({ target: systemConfig.key, set: { value } })
503
+ .run();
504
+ }
505
+
506
+ /**
507
+ * Ask, then perform (or skip) the repoint for a just-deployed `dns_internal`
508
+ * provider. Exported for tests; `deployModuleImpl` calls this after the DNS
509
+ * backfill when the deployed module provides `dns_internal`.
510
+ */
511
+ export async function repointDnsPrimaryForProvider(
512
+ moduleId: string,
513
+ db: DbClient,
514
+ ask: typeof askConfirm = askConfirm,
515
+ ): Promise<void> {
516
+ const serverIp = getModuleSystems(moduleId, db)[0]?.ipv4_address;
517
+ if (!serverIp) {
518
+ log.warn(
519
+ `dns_internal provider '${moduleId}' has no deployed system — skipping dns.primary repoint`,
520
+ );
521
+ return;
522
+ }
523
+ const plan = planDnsRepoint({
524
+ serverIp,
525
+ priorPrimary: readSystemConfigValue(db, 'dns.primary'),
526
+ existingFallback: readSystemConfigValue(db, 'dns.fallback'),
527
+ });
528
+ if (!plan.needsRepoint) {
529
+ log.info(`dns.primary already points at ${serverIp} — no repoint needed`);
530
+ return;
531
+ }
532
+
533
+ const demoteNote = plan.newFallback
534
+ ? ` The old primary ${plan.priorPrimary} moves to dns.fallback (${plan.newFallback}).`
535
+ : '';
536
+ const opts = {
537
+ scope: `deploy:${moduleId}`,
538
+ key: 'repoint_dns_primary',
539
+ message: `Deploying '${moduleId}' made it the fleet's internal DNS resolver. Point dns.primary at ${serverIp}?${demoteNote}`,
540
+ defaultValue: false,
541
+ };
542
+ // Called DIRECTLY, not inside withInterviewSession: deployModuleImpl already
543
+ // opened the terminal responder above and closes it in its finally. A second
544
+ // responder instance would race the first on stdin (design.md D5).
545
+ let confirmed: boolean;
546
+ try {
547
+ confirmed = await ask(
548
+ process.stdin.isTTY ? opts : { ...opts, timeoutMs: DNS_REPOINT_HEADLESS_TIMEOUT_MS },
549
+ );
550
+ } catch (error) {
551
+ if (error instanceof InterviewUnansweredError || error instanceof InterviewAbandonedError) {
552
+ log.warn(
553
+ `dns.primary repoint skipped — nobody answered the confirm (${opts.scope}:${opts.key}). Stage the answer (celilo events respond --values) or set dns.primary manually later.`,
554
+ );
555
+ return;
556
+ }
557
+ throw error;
558
+ }
559
+
560
+ if (!confirmed) {
561
+ log.info('dns.primary repoint declined — fleet resolver left unchanged');
562
+ return;
563
+ }
564
+
565
+ writeSystemConfigValue(db, 'dns.primary', serverIp);
566
+ if (plan.newFallback !== undefined) {
567
+ writeSystemConfigValue(db, 'dns.fallback', plan.newFallback);
568
+ }
569
+ log.success(
570
+ `dns.primary repointed at internal resolver ${serverIp}${plan.newFallback ? ` (dns.fallback: ${plan.newFallback})` : ''}`,
571
+ );
572
+ }
573
+
391
574
  async function deployModuleImpl(
392
575
  moduleId: string,
393
576
  db: DbClient,
@@ -641,26 +824,10 @@ async function deployModuleImpl(
641
824
 
642
825
  log.success('Templates generated');
643
826
 
644
- // Pre-flight: refuse before contacting any system if the generated project
645
- // does not match the module tree it was generated from (D6).
646
- //
647
- // This is the check that would have turned celilo#925 from a silent success
648
- // into a refusal at the point of the fault. A verbatim role asset that
649
- // failed to refresh is invisible downstream: `cp` reports no error, Ansible
650
- // copies the stale bytes and reports `ok`, unchanged, and the module's
651
- // version advances past code that never shipped. Local, milliseconds, no
652
- // SSH. It deliberately does NOT verify after the deploy — Ansible's
653
- // `changed` is truthful about what Ansible did, and the lie is upstream of
654
- // Ansible.
655
- const { refuseIfGeneratedIsStale } = await import('../module/packaging/generated-plane');
656
- const staleGenerated = await refuseIfGeneratedIsStale(
657
- moduleId,
658
- module.sourcePath,
659
- generatedPath,
660
- );
661
- if (staleGenerated) {
662
- return { success: false, error: staleGenerated, phases };
663
- }
827
+ // The generated-tree staleness pre-flight (D6 of module-integrity-rigor)
828
+ // retired here (D5 of control-plane-stops-building-modules): the tree is
829
+ // rendered for THIS deploy and deleted when it succeeds, so nothing
830
+ // persists that could be stale relative to what ships.
664
831
 
665
832
  // Run validate_config hook if defined (e.g., credential validation via Playwright)
666
833
  if (manifest.hooks?.validate_config) {
@@ -724,6 +891,7 @@ async function deployModuleImpl(
724
891
  requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
725
892
  systems: getModuleSystems(moduleId, db),
726
893
  remoteAccess: remoteAccessPolicy(moduleId, db),
894
+ hookStores: () => createHookStores(db, moduleId),
727
895
  },
728
896
  );
729
897
 
@@ -863,6 +1031,7 @@ async function deployModuleImpl(
863
1031
  requiredCapabilities: (manifest.requires?.capabilities ?? []).map((c) => c.name),
864
1032
  systems: getModuleSystems(moduleId, db),
865
1033
  remoteAccess: remoteAccessPolicy(moduleId, db),
1034
+ hookStores: () => createHookStores(db, moduleId),
866
1035
  },
867
1036
  };
868
1037
  },
@@ -1165,6 +1334,7 @@ async function deployModuleImpl(
1165
1334
  requiredCapabilities: providerManifest.requires.capabilities.map((c) => c.name),
1166
1335
  systems: getModuleSystems(capRecord.moduleId, db),
1167
1336
  remoteAccess: remoteAccessPolicy(capRecord.moduleId, db),
1337
+ hookStores: () => createHookStores(db, capRecord.moduleId),
1168
1338
  },
1169
1339
  );
1170
1340
 
@@ -1483,6 +1653,7 @@ async function deployModuleImpl(
1483
1653
  requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
1484
1654
  systems: getModuleSystems(moduleId, db),
1485
1655
  remoteAccess: remoteAccessPolicy(moduleId, db),
1656
+ hookStores: () => createHookStores(db, moduleId),
1486
1657
  },
1487
1658
  };
1488
1659
  },
@@ -1608,6 +1779,13 @@ async function deployModuleImpl(
1608
1779
  `DNS backfill failed for '${moduleId}': ${msg}. Some internal DNS records may need manual setup.`,
1609
1780
  );
1610
1781
  }
1782
+
1783
+ // The dns_internal handoff (module-orchestrator-primitives slice 3,
1784
+ // design D5): celilo repoints dns.primary at the resolver it just
1785
+ // deployed, gated on an interview. knot's on_install no longer does
1786
+ // this. Runs after the backfill so a failed deploy never repoints the
1787
+ // fleet at a resolver that never came up.
1788
+ await repointDnsPrimaryForProvider(moduleId, db);
1611
1789
  }
1612
1790
 
1613
1791
  // Announce each deployed system on the bus (D5/D6) — one
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { closeDb } from '../db/client';
7
7
  import { runMigrations } from '../db/migrate';
8
+ import { resetTestDbPath } from '../test-utils/db-path';
8
9
  import {
9
10
  InFlightError,
10
11
  OPERATIONS_SWEEP_PATTERN,
@@ -30,7 +31,7 @@ describe('module-operations', () => {
30
31
 
31
32
  afterEach(() => {
32
33
  closeDb();
33
- process.env.CELILO_DB_PATH = undefined;
34
+ resetTestDbPath();
34
35
  try {
35
36
  rmSync(dir, { recursive: true, force: true });
36
37
  } catch {
@@ -10,6 +10,7 @@ import { ModuleManifestSchema } from '../manifest/schema';
10
10
  import { ModuleSubscriptionSchema } from '../manifest/schema';
11
11
  import type { ModuleManifest } from '../manifest/schema';
12
12
  import { setupTestDatabaseAt as migrateDbFile } from '../test-utils/database';
13
+ import { resetTestDbPath } from '../test-utils/db-path';
13
14
  import {
14
15
  registerModuleSubscriptions,
15
16
  resolveSubscription,
@@ -341,7 +342,7 @@ describe('resyncAllSubscriptions (ISS-0088)', () => {
341
342
  });
342
343
  afterEach(() => {
343
344
  closeDb();
344
- process.env.CELILO_DB_PATH = undefined;
345
+ resetTestDbPath();
345
346
  process.env.EVENT_BUS_DB = undefined;
346
347
  try {
347
348
  rmSync(dir, { recursive: true, force: true });
@@ -1,8 +1,9 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import { execSync } from 'node:child_process';
3
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
5
+ import { dirname, join } from 'node:path';
6
+ import { classifyModulePath } from '../../module/packaging/package-rules';
6
7
  import { checkGitHygiene, checkModuleStale } from './git-hygiene';
7
8
 
8
9
  interface TempRepo {
@@ -18,8 +19,19 @@ interface TempRepo {
18
19
  */
19
20
  function makeTempRepo(): TempRepo {
20
21
  const dir = mkdtempSync(join(tmpdir(), 'celilo-git-hygiene-'));
22
+ // Bound every git child. A git call that hangs used to run until bun's
23
+ // per-test timeout killed it, and bun's dangling-process kill left the
24
+ // WHOLE run parked (reproduced for celilo#1282; the park itself is
25
+ // celilo#1246). Erroring the test instead keeps the failure visible and
26
+ // the run exitable. 30s per command stays under the 60s per-test ceiling
27
+ // this suite runs with.
21
28
  const exec = (cmd: string): string =>
22
- execSync(cmd, { cwd: dir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
29
+ execSync(cmd, {
30
+ cwd: dir,
31
+ encoding: 'utf-8',
32
+ stdio: ['ignore', 'pipe', 'pipe'],
33
+ timeout: 30_000,
34
+ });
23
35
  exec('git init -q');
24
36
  exec('git config user.email "test@example.com"');
25
37
  exec('git config user.name "Test"');
@@ -88,6 +100,113 @@ describe('checkModuleStale', () => {
88
100
  });
89
101
  });
90
102
 
103
+ describe('staleness reach vs the packager (celilo#1270)', () => {
104
+ // Paths planted to span the classifications classifyModulePath produces.
105
+ // The expected staleness outcome is NOT hand-written per path: each test
106
+ // asks classifyModulePath what the packager does with the path and holds
107
+ // the staleness gate to the same answer.
108
+ const planted = [
109
+ 'e2e/split-horizon-views.test.ts',
110
+ 'e2e/fixtures/cases.json',
111
+ 'scripts/install.sh',
112
+ 'scripts/tsconfig.json',
113
+ 'ansible/playbook.yml',
114
+ 'terraform/main.tf',
115
+ 'schema/tables.sql',
116
+ 'hooks/run.test.ts',
117
+ 'generated/report.json',
118
+ 'state/last-run.json',
119
+ ];
120
+
121
+ /** Repo with only manifest.yml committed; returns `commit(rel)` helper. */
122
+ function repoWithManifest() {
123
+ const repo = makeTempRepo();
124
+ writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n');
125
+ repo.exec('git add manifest.yml && git commit -q -m manifest');
126
+ const commit = (rel: string) => {
127
+ const abs = join(repo.dir, rel);
128
+ mkdirSync(dirname(abs), { recursive: true });
129
+ writeFileSync(abs, 'planted\n');
130
+ repo.exec(`git add ${rel} && git commit -q -m ${JSON.stringify(rel)}`);
131
+ };
132
+ return { repo, commit };
133
+ }
134
+
135
+ test(
136
+ 'a commit touching only e2e/ does not mark the manifest stale',
137
+ () => {
138
+ const { repo, commit } = repoWithManifest();
139
+ try {
140
+ commit('e2e/split-horizon-views.test.ts');
141
+ expect(checkModuleStale(repo.dir)).toBeNull();
142
+ } finally {
143
+ repo.cleanup();
144
+ }
145
+ },
146
+ { timeout: 30_000 },
147
+ );
148
+
149
+ test(
150
+ 'a commit touching scripts/, ansible/, terraform/ or schema/ still fires',
151
+ () => {
152
+ for (const rel of [
153
+ 'scripts/install.sh',
154
+ 'ansible/playbook.yml',
155
+ 'terraform/main.tf',
156
+ 'schema/tables.sql',
157
+ ]) {
158
+ const { repo, commit } = repoWithManifest();
159
+ try {
160
+ commit(rel);
161
+ expect(classifyModulePath(rel)).toBe('package');
162
+ expect(checkModuleStale(repo.dir)).not.toBeNull();
163
+ } finally {
164
+ repo.cleanup();
165
+ }
166
+ }
167
+ },
168
+ { timeout: 30_000 },
169
+ );
170
+
171
+ test(
172
+ 'reach parity: the gate fires exactly for paths the packager ships',
173
+ async () => {
174
+ for (const rel of planted) {
175
+ const { repo, commit } = repoWithManifest();
176
+ try {
177
+ commit(rel);
178
+ const stale = checkModuleStale(repo.dir);
179
+ if (classifyModulePath(rel) === 'package') {
180
+ expect(stale).not.toBeNull();
181
+ } else {
182
+ expect(stale).toBeNull();
183
+ }
184
+ } finally {
185
+ repo.cleanup();
186
+ }
187
+ }
188
+ },
189
+ { timeout: 30_000 },
190
+ );
191
+
192
+ test(
193
+ 'non-shipped commits between manifest and a shipped change stay quiet until shipped bytes move',
194
+ () => {
195
+ const { repo, commit } = repoWithManifest();
196
+ try {
197
+ commit('e2e/split-horizon-views.test.ts');
198
+ commit('scripts/tsconfig.json');
199
+ expect(checkModuleStale(repo.dir)).toBeNull();
200
+ commit('scripts/install.sh');
201
+ expect(checkModuleStale(repo.dir)).not.toBeNull();
202
+ } finally {
203
+ repo.cleanup();
204
+ }
205
+ },
206
+ { timeout: 30_000 },
207
+ );
208
+ });
209
+
91
210
  describe('checkGitHygiene', () => {
92
211
  test('all ok on a fresh, clean repo with manifest as latest commit', () => {
93
212
  const repo = makeTempRepo();
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { join } from 'node:path';
3
+ import { classifyModulePath } from '../../module/packaging/package-rules';
3
4
  import { collectGitInfo, makeRealGitRunner } from '../../module/packaging/release-metadata';
4
5
  import type { Check } from './types';
5
6
 
@@ -19,10 +20,14 @@ export interface StalenessIssue {
19
20
  * anywhere should still talk to lunacycle's git history).
20
21
  */
21
22
  function lastCommitTouching(cwd: string, pathspec: string[]): string | null {
23
+ // timeout: a hung git degrades to the "can't determine — skip" path (status
24
+ // null) instead of hanging `module check` forever. 30s is enormous against
25
+ // the milliseconds a bounded `git log -1` takes on any real repo.
22
26
  const r = spawnSync('git', ['log', '-1', '--format=%H', '--', ...pathspec], {
23
27
  cwd,
24
28
  stdio: ['ignore', 'pipe', 'ignore'],
25
29
  encoding: 'utf-8',
30
+ timeout: 30_000,
26
31
  });
27
32
  if (r.status !== 0) return null;
28
33
  const sha = r.stdout.trim();
@@ -33,17 +38,90 @@ function isAncestor(cwd: string, maybeAncestor: string, descendant: string): boo
33
38
  const r = spawnSync('git', ['merge-base', '--is-ancestor', maybeAncestor, descendant], {
34
39
  cwd,
35
40
  stdio: 'ignore',
41
+ timeout: 30_000,
36
42
  });
37
43
  return r.status === 0;
38
44
  }
39
45
 
46
+ /**
47
+ * One commit from `git log --name-only`: its SHA and the repo-root-relative
48
+ * paths it changed. A merge commit carries an empty file list (git log omits
49
+ * diffs for merges), so it never qualifies as a src commit — which is right,
50
+ * a merge changes no authored byte of its own.
51
+ */
52
+ interface CommitWithPaths {
53
+ sha: string;
54
+ paths: string[];
55
+ }
56
+
57
+ /**
58
+ * The commits touching `pathspec`, newest first, with the repo-root-relative
59
+ * paths each one changed. `git log --name-only` prints repo-root-relative
60
+ * paths even when cwd is a subdirectory (measured: it does not honor cwd for
61
+ * output), so the caller strips the prefix itself via `show-prefix`.
62
+ */
63
+ function listCommitsTouching(cwd: string, pathspec: string): CommitWithPaths[] {
64
+ const r = spawnSync(
65
+ 'git',
66
+ ['-c', 'core.quotePath=false', 'log', '--format=%H', '--name-only', '--', pathspec],
67
+ { cwd, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' },
68
+ );
69
+ if (r.status !== 0) return [];
70
+ const commits: CommitWithPaths[] = [];
71
+ let current: CommitWithPaths | null = null;
72
+ for (const line of (r.stdout ?? '').split('\n')) {
73
+ const trimmed = line.trim();
74
+ if (!trimmed) continue;
75
+ if (/^[0-9a-f]{40}$/.test(trimmed)) {
76
+ current = { sha: trimmed, paths: [] };
77
+ commits.push(current);
78
+ } else if (current) {
79
+ current.paths.push(trimmed);
80
+ }
81
+ }
82
+ return commits;
83
+ }
84
+
85
+ /**
86
+ * SHA of the last commit that touched SHIPPED module source, or null when no
87
+ * commit qualifies (no history, or only non-shipped paths ever committed).
88
+ *
89
+ * The staleness scan's reach is the PACKAGER's rule, not a second list beside
90
+ * it (celilo#1270). A path counts as source exactly when `classifyModulePath`
91
+ * calls it `package` — the same answer that decides whether the path lands in
92
+ * the .netapp. So a commit touching only `e2e/**`, `*.test.ts`, or
93
+ * `tsconfig.json` changes no installed byte and must not read as drift.
94
+ *
95
+ * Excluding manifest.yml from the "src" scan is the gate's own semantics, not
96
+ * packaging: we want to know if anything else shipped past it. node_modules,
97
+ * dist, and other build outputs are gitignored and so never appear here.
98
+ */
99
+ function lastShippedSourceCommit(moduleDir: string): string | null {
100
+ const prefixR = spawnSync('git', ['rev-parse', '--show-prefix'], {
101
+ cwd: moduleDir,
102
+ stdio: ['ignore', 'pipe', 'ignore'],
103
+ encoding: 'utf-8',
104
+ });
105
+ if (prefixR.status !== 0) return null;
106
+ const repoPrefix = (prefixR.stdout ?? '').trim();
107
+ for (const commit of listCommitsTouching(moduleDir, moduleDir)) {
108
+ for (const p of commit.paths) {
109
+ if (repoPrefix && !p.startsWith(repoPrefix)) continue;
110
+ const rel = repoPrefix ? p.slice(repoPrefix.length) : p;
111
+ if (rel === 'manifest.yml') continue;
112
+ if (classifyModulePath(rel) === 'package') return commit.sha;
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+
40
118
  /**
41
119
  * Detect "I edited module src but forgot to bump (or touch) manifest.yml."
42
120
  *
43
- * Returns null when the manifest is the most recently-touched file in the
44
- * dir (or when neither side has any commit history — e.g. brand-new module
45
- * not yet committed). Returns a StalenessIssue when src has commits AFTER
46
- * the last manifest.yml change — the operator must bump the manifest
121
+ * Returns null when the manifest is the most recently-touched shipped file in
122
+ * the dir (or when neither side has any commit history — e.g. brand-new module
123
+ * not yet committed). Returns a StalenessIssue when shipped src has commits
124
+ * AFTER the last manifest.yml change — the operator must bump the manifest
47
125
  * (semver change → reset +N to 1) or just touch it (release-only change →
48
126
  * auto-bump +N), then re-publish.
49
127
  *
@@ -54,16 +132,7 @@ function isAncestor(cwd: string, maybeAncestor: string, descendant: string): boo
54
132
  export function checkModuleStale(moduleDir: string): StalenessIssue | null {
55
133
  const manifestPath = join(moduleDir, 'manifest.yml');
56
134
  const lastManifest = lastCommitTouching(moduleDir, [manifestPath]);
57
- // Excluding manifest.yml from the "src" pathspec is the whole point — we
58
- // want to know if anything ELSE in the dir moved past it. node_modules
59
- // and common build outputs are gitignored already, but be explicit
60
- // defensively.
61
- const lastSrc = lastCommitTouching(moduleDir, [
62
- moduleDir,
63
- `:(exclude)${manifestPath}`,
64
- `:(exclude)${moduleDir}/node_modules`,
65
- `:(exclude)${moduleDir}/dist`,
66
- ]);
135
+ const lastSrc = lastShippedSourceCommit(moduleDir);
67
136
  if (!lastManifest || !lastSrc) return null;
68
137
  if (lastSrc === lastManifest) return null;
69
138
  if (!isAncestor(moduleDir, lastManifest, lastSrc)) return null;
@@ -7,6 +7,7 @@ import { eq } from 'drizzle-orm';
7
7
  import type { DbClient } from '../db/client';
8
8
  import { portForwards } from '../db/schema';
9
9
  import { setupTestDatabaseAt } from '../test-utils/database';
10
+ import { resetTestDbPath } from '../test-utils/db-path';
10
11
  import { deleteClaimedRows } from './capability-table-rows';
11
12
  import { buildPortForwardStore } from './port-forwards';
12
13
 
@@ -27,7 +28,7 @@ describe('port-forward store', () => {
27
28
  });
28
29
  afterEach(() => {
29
30
  db.$client.close();
30
- process.env.CELILO_DB_PATH = undefined;
31
+ resetTestDbPath();
31
32
  try {
32
33
  rmSync(dir, { recursive: true, force: true });
33
34
  } catch {
@@ -17,6 +17,7 @@ import { join } from 'node:path';
17
17
  import { defineEvents, openBus } from '@celilo/event-bus';
18
18
  import { closeDb, getDb } from '../db/client';
19
19
  import { runMigrations } from '../db/migrate';
20
+ import { resetTestDbPath } from '../test-utils/db-path';
20
21
  import { type AspectRequiredPayload, EVENT_TYPES } from './bus-interview';
21
22
  import { startProgrammaticResponder } from './programmatic-responder';
22
23
 
@@ -72,7 +73,7 @@ describe('programmatic responder — aspect.required consent (#262)', () => {
72
73
  afterEach(() => {
73
74
  closeDb();
74
75
  rmSync(dir, { recursive: true, force: true });
75
- process.env.CELILO_DB_PATH = undefined;
76
+ resetTestDbPath();
76
77
  });
77
78
 
78
79
  it('approves (consented=true) when the policy approves the module', async () => {
@@ -27,6 +27,7 @@ import {
27
27
  modules,
28
28
  } from '../db/schema';
29
29
  import type { BaseModuleAspect } from '../manifest/schema';
30
+ import { resetTestDbPath } from '../test-utils/db-path';
30
31
  import { upsertModuleConfig } from './module-config';
31
32
  import { executeProxmoxReconcile, planProxmoxReconcile } from './proxmox-reconcile';
32
33
 
@@ -153,7 +154,7 @@ describe('proxmox-reconcile', () => {
153
154
 
154
155
  afterEach(() => {
155
156
  closeDb();
156
- process.env.CELILO_DB_PATH = undefined;
157
+ resetTestDbPath();
157
158
  try {
158
159
  rmSync(dir, { recursive: true, force: true });
159
160
  } catch {