@celilo/cli 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/AGENTS.md +7 -4
  2. package/CELILO_CORE_MODULES.md +7 -6
  3. package/CELILO_SUBSYSTEMS.md +52 -6
  4. package/MODULE_PRIMITIVES.md +164 -0
  5. package/README.md +5 -5
  6. package/drizzle/0015_port_forwards.sql +12 -0
  7. package/drizzle/meta/_journal.json +7 -0
  8. package/package.json +14 -6
  9. package/schemas/system_config.json +1 -1
  10. package/src/ansible/inventory.test.ts +1 -1
  11. package/src/ansible/inventory.ts +3 -3
  12. package/src/api/protocol.test.ts +1 -1
  13. package/src/api/remote-client.test.ts +1 -1
  14. package/src/api/serve.ts +5 -5
  15. package/src/capabilities/public-web-publish.test.ts +209 -0
  16. package/src/capabilities/validation.test.ts +1 -1
  17. package/src/capabilities/well-known.test.ts +1 -1
  18. package/src/capabilities/well-known.ts +2 -2
  19. package/src/cli/command-tree-parser.ts +3 -1
  20. package/src/cli/commands/api.ts +1 -1
  21. package/src/cli/commands/apt-upgrade.test.ts +33 -0
  22. package/src/cli/commands/apt-upgrade.ts +63 -0
  23. package/src/cli/commands/backup-pull.ts +1 -1
  24. package/src/cli/commands/commands-json.ts +29 -0
  25. package/src/cli/commands/completion.ts +1 -1
  26. package/src/cli/commands/events.ts +1 -1
  27. package/src/cli/commands/module-changeset.ts +1 -1
  28. package/src/cli/commands/module-import.ts +1 -1
  29. package/src/cli/commands/module-list.ts +16 -2
  30. package/src/cli/commands/module-publish.ts +1 -1
  31. package/src/cli/commands/module-remove.ts +26 -1
  32. package/src/cli/commands/module-types.ts +1 -1
  33. package/src/cli/commands/module-upgrade.ts +1 -1
  34. package/src/cli/commands/module-version.ts +1 -1
  35. package/src/cli/commands/module-where.test.ts +26 -0
  36. package/src/cli/commands/module-where.ts +130 -0
  37. package/src/cli/commands/proxmox-node-list.ts +1 -1
  38. package/src/cli/commands/publish/alpha.test.ts +1 -1
  39. package/src/cli/commands/publish/alpha.ts +3 -3
  40. package/src/cli/commands/publish/changesets.ts +1 -1
  41. package/src/cli/commands/publish/global-install.ts +6 -2
  42. package/src/cli/commands/publish/helpers.ts +21 -14
  43. package/src/cli/commands/publish/index.ts +4 -4
  44. package/src/cli/commands/publish/plan.ts +3 -3
  45. package/src/cli/commands/publish/preflight.ts +2 -2
  46. package/src/cli/commands/publish/types.ts +2 -2
  47. package/src/cli/commands/publish/workspace.test.ts +1 -1
  48. package/src/cli/commands/publish/workspace.ts +2 -2
  49. package/src/cli/commands/registry-owner.test.ts +166 -0
  50. package/src/cli/commands/registry-owner.ts +124 -0
  51. package/src/cli/commands/registry-token.test.ts +109 -0
  52. package/src/cli/commands/registry-token.ts +194 -0
  53. package/src/cli/commands/restore.ts +1 -1
  54. package/src/cli/commands/service-add-proxmox.ts +1 -1
  55. package/src/cli/commands/service-list.ts +15 -2
  56. package/src/cli/commands/subscribers-list.ts +1 -1
  57. package/src/cli/commands/system-apply-config-equivalence.test.ts +1 -1
  58. package/src/cli/commands/system-apply-config.ts +1 -1
  59. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  60. package/src/cli/commands/system-init.ts +2 -2
  61. package/src/cli/commands/token.test.ts +26 -0
  62. package/src/cli/commands/token.ts +160 -0
  63. package/src/cli/completion.ts +21 -0
  64. package/src/cli/generate-zsh-completion.test.ts +22 -4
  65. package/src/cli/generate-zsh-completion.ts +7 -3
  66. package/src/cli/index.ts +170 -7
  67. package/src/cli/parser.ts +1 -1
  68. package/src/cli/restore-command.test.ts +1 -1
  69. package/src/db/client.ts +1 -1
  70. package/src/db/schema.ts +50 -7
  71. package/src/hooks/capability-loader-firewall.test.ts +4 -3
  72. package/src/hooks/capability-loader.ts +100 -10
  73. package/src/hooks/define-hook.test.ts +24 -0
  74. package/src/hooks/executor.test.ts +1 -1
  75. package/src/hooks/executor.ts +2 -2
  76. package/src/hooks/types.ts +1 -1
  77. package/src/manifest/contracts/v1.ts +2 -2
  78. package/src/manifest/schema.ts +13 -13
  79. package/src/manifest/template-validator.ts +1 -1
  80. package/src/module/packaging/build.ts +19 -0
  81. package/src/module/packaging/workspace-deps.test.ts +94 -0
  82. package/src/module/packaging/workspace-deps.ts +185 -0
  83. package/src/module/versioning/changeset-version.ts +1 -1
  84. package/src/policy/no-hand-built-ssh.test.ts +90 -0
  85. package/src/registry/client.test.ts +86 -0
  86. package/src/registry/client.ts +67 -1
  87. package/src/services/api-access.ts +1 -1
  88. package/src/services/aspect-approvals.ts +1 -1
  89. package/src/services/aspect-runner.ts +3 -3
  90. package/src/services/aspect-template-resolver.test.ts +1 -1
  91. package/src/services/aspect-template-resolver.ts +1 -1
  92. package/src/services/build-bus/delivery-events.ts +1 -1
  93. package/src/services/build-bus/fan-out.ts +1 -1
  94. package/src/services/build-bus/hook-dispatch.ts +1 -1
  95. package/src/services/build-bus/receiver-server.ts +1 -1
  96. package/src/services/build-bus/status.test.ts +1 -1
  97. package/src/services/build-bus/status.ts +1 -1
  98. package/src/services/build-bus/subscriber-store.ts +1 -1
  99. package/src/services/bus-interview.ts +1 -1
  100. package/src/services/celilo-events.ts +1 -1
  101. package/src/services/celilo-mgmt-hooks.test.ts +1 -1
  102. package/src/services/cross-module-read.ts +1 -1
  103. package/src/services/deploy-posture.ts +1 -1
  104. package/src/services/deployed-systems.test.ts +1 -1
  105. package/src/services/deployed-systems.ts +4 -4
  106. package/src/services/dns-provider-backfill.ts +2 -2
  107. package/src/services/events-daemon.ts +1 -1
  108. package/src/services/machine-pool.ts +3 -3
  109. package/src/services/module-deploy.ts +93 -8
  110. package/src/services/module-subscriptions.ts +1 -1
  111. package/src/services/module-types-generator.ts +1 -1
  112. package/src/services/module-validator/git-hygiene.ts +1 -1
  113. package/src/services/port-forwards.test.ts +92 -0
  114. package/src/services/port-forwards.ts +86 -0
  115. package/src/services/proxmox-reconcile.ts +2 -2
  116. package/src/services/public-web-republish.test.ts +189 -0
  117. package/src/services/public-web-republish.ts +84 -0
  118. package/src/services/restore-from-file.ts +1 -1
  119. package/src/services/restore-preflight.ts +1 -1
  120. package/src/services/system-identity.ts +1 -1
  121. package/src/services/system-init.test.ts +2 -2
  122. package/src/services/system-init.ts +2 -2
  123. package/src/services/web-route-cleanup.test.ts +250 -0
  124. package/src/services/web-route-cleanup.ts +144 -0
  125. package/src/templates/generator.ts +2 -2
  126. package/src/types/infrastructure.ts +1 -1
  127. package/src/variables/computed/computed-integration.test.ts +1 -1
  128. package/src/variables/computed/evaluate.ts +1 -1
  129. package/src/variables/computed/parse.ts +1 -1
  130. package/src/variables/context.test.ts +1 -1
  131. package/src/variables/context.ts +5 -5
  132. package/src/variables/lxc-nameserver.test.ts +1 -1
  133. package/src/variables/resolver.ts +2 -2
  134. package/src/variables/types.ts +1 -1
  135. package/src/api/protocol.ts +0 -159
  136. package/src/api/remote-client.ts +0 -218
  137. package/src/cli/command-registry.ts +0 -1488
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Static-config subscriber store for the build bus
3
- * ([[v2/BUILD_BUS.md]] Phase 2-lite).
3
+ * ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 2-lite).
4
4
  *
5
5
  * The first cut of cross-machine event distribution skips the
6
6
  * registry-server switchboard described in §6 of the spec — instead
@@ -9,7 +9,7 @@
9
9
  * nothing answers, the operator sees the unanswered query via
10
10
  * `celilo events list-pending` and fixes the responder setup.
11
11
  *
12
- * See `infra/design/INTERACTIVE_DEPLOYS_VIA_BUS.md`.
12
+ * See `infra/openspec/changes/interactive-deploys-via-event-bus/proposal.md`.
13
13
  */
14
14
 
15
15
  import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
@@ -64,7 +64,7 @@ export interface UninstallFailedPayload {
64
64
  * module) has come up / been torn down. Carries the system's identity so a
65
65
  * subscriber (e.g. a dns_internal provider) can register/deregister internal
66
66
  * DNS records without re-querying the deploying module's config.
67
- * v2/INTERNAL_DNS_DHCP_AND_SPLIT_HORIZON.md D5.
67
+ * openspec/specs/internal-dns-split-horizon/spec.md D5.
68
68
  */
69
69
  export interface SystemCreatedPayload {
70
70
  /** The module whose deploy produced this system (lets handlers resolve zones, etc.). */
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Behavior tests for the celilo-mgmt module's on_backup / on_restore
3
- * hooks (Phase 3 of v2/SYSTEM_BACKUP_TERRAFORM_STATE.md).
3
+ * hooks (Phase 3 of openspec/specs/management-server-backup/spec.md).
4
4
  *
5
5
  * We import the hook scripts directly and call their handlers with a
6
6
  * synthesized HookContext, then assert on the side-effects (files
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Materialization helpers for the `cross_module_read` privilege.
3
3
  *
4
- * Phase 2 of v2/SYSTEM_BACKUP_TERRAFORM_STATE.md. When an allow-listed
4
+ * Phase 2 of openspec/specs/management-server-backup/spec.md. When an allow-listed
5
5
  * module (today: only celilo-mgmt) declares `cross_module_read` in its
6
6
  * `requires.capabilities`, its on_backup hook receives a
7
7
  * `cross_module_root` input pointing at a directory mirroring OTHER
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Deploy posture — fast vs. safe — for `celilo module upgrade` (ISS-0138,
3
- * v2/BUILD_BUS.md).
3
+ * openspec/changes/build-bus-poll-cd/proposal.md).
4
4
  *
5
5
  * CD is CI-driven, so no operator is present to pass `--no-backup`. The posture
6
6
  * (fast = skip backup + extended verify; safe = backup + full verify) is
@@ -17,7 +17,7 @@ const TEST_DB_PATH = './test-deployed-systems.db';
17
17
 
18
18
  /**
19
19
  * Coverage for the one-time `module_systems` upgrade backfill
20
- * (v2/MODULE_SYSTEMS_ADDRESSING.md): a deployment created before 0007 has its
20
+ * (openspec/specs/module-systems-addressing/spec.md): a deployment created before 0007 has its
21
21
  * host data in module_configs / ip_allocations / module_infrastructure but an
22
22
  * empty module_systems, and the refactored hooks resolve to no system. The
23
23
  * backfill reconstructs it. Mirrors the real turnip prod state (caddy etc.:
@@ -15,7 +15,7 @@ import type { InfraSystemFields } from '../variables/types';
15
15
 
16
16
  /**
17
17
  * The deployment-STATE layer: a module's 0..N deployed systems
18
- * (v2/MODULE_SYSTEMS_ADDRESSING.md). Replaces the scalar `target_ip`/`vmid`
18
+ * (openspec/specs/module-systems-addressing/spec.md). Replaces the scalar `target_ip`/`vmid`
19
19
  * rows that used to live in module_configs and the single-result
20
20
  * `getModuleHostAndIp`. There is deliberately no "get THE system" helper —
21
21
  * callers work with the array so the 0/1/N reality stays visible.
@@ -216,7 +216,7 @@ function asZone(value: string | undefined): NetworkZone | null {
216
216
  * `requires.system`, normalized to name `main`, or one `requires.systems`
217
217
  * entry), so this records that single host from `hostname` config + the
218
218
  * resolved IP. Returns the systems it recorded ([] for an API-only module with
219
- * no host — e.g. namecheap). See v2/MODULE_SYSTEMS_ADDRESSING.md.
219
+ * no host — e.g. namecheap). See openspec/specs/module-systems-addressing/spec.md.
220
220
  */
221
221
  export async function recordDeployedSystemForModule(
222
222
  moduleId: string,
@@ -282,7 +282,7 @@ export async function recordDeployedSystemForModule(
282
282
 
283
283
  /**
284
284
  * One-time upgrade backfill: populate `module_systems` for deployments that
285
- * predate `0007_module_systems` (v2/MODULE_SYSTEMS_ADDRESSING.md). A DB created
285
+ * predate `0007_module_systems` (openspec/specs/module-systems-addressing/spec.md). A DB created
286
286
  * before the target_ip → systems refactor has its host data in
287
287
  * `module_configs.target_ip` / `vmid` / `zone` + `ip_allocations` +
288
288
  * `module_infrastructure`, but an empty `module_systems` — so `$infra:` and
@@ -374,7 +374,7 @@ function prefixOf(subnetCidr: string | undefined): number {
374
374
  * Build the `$infra:<name>.<field>` lookup for a module — one entry per deployed
375
375
  * system, keyed by its `name`. `cidr` is derived from `ipv4_address` + the
376
376
  * zone's prefix (from `network.<zone>.subnet` in system config). Consumed by the
377
- * variable resolver at generate time. v2/MODULE_SYSTEMS_ADDRESSING.md.
377
+ * variable resolver at generate time. openspec/specs/module-systems-addressing/spec.md.
378
378
  */
379
379
  export function buildInfraSystemsMap(
380
380
  moduleId: string,
@@ -7,7 +7,7 @@
7
7
  * every currently-deployed system in its zones by invoking its OWN
8
8
  * `on_system_event` hook once per host. The ongoing case (a system deploying
9
9
  * AFTER the provider) is handled by the provider's `system.created.*`
10
- * subscription. See v2/EVENT_DRIVEN_HOOK_SUBSCRIPTIONS.md.
10
+ * subscription. See openspec/specs/event-driven-hook-subscriptions/spec.md.
11
11
  *
12
12
  * All DNS mechanics (which zones, register vs delete) live in the module's
13
13
  * hook; this file only decides WHICH hosts and invokes the hook. That is the
@@ -74,7 +74,7 @@ export async function backfillProviderDns(
74
74
  const failures: string[] = [];
75
75
 
76
76
  // One register per deployed system across all modules — a module with N
77
- // hosts backfills N records (v2/MODULE_SYSTEMS_ADDRESSING.md).
77
+ // hosts backfills N records (openspec/specs/module-systems-addressing/spec.md).
78
78
  for (const mod of deployed) {
79
79
  for (const sys of getModuleSystems(mod.id, db)) {
80
80
  const result = await runNamedHook(moduleId, 'on_system_event' as HookName, db, logger, {
@@ -171,7 +171,7 @@ export function renderSystemdUnit(input: UnitInputs): string {
171
171
  : `journalctl --user -u ${SYSTEMD_UNIT_NAME}`;
172
172
  return `[Unit]
173
173
  Description=Celilo SQLite Event Bus Dispatcher
174
- Documentation=https://github.com/psbanka/infra/blob/main/design/SQLITE_EVENT_BUS.md
174
+ Documentation=https://github.com/psbanka/infra/blob/main/openspec/specs/event-bus/spec.md
175
175
  After=network.target
176
176
 
177
177
  [Service]
@@ -242,17 +242,17 @@ export async function listMachines(filters?: MachineFilters): Promise<Machine[]>
242
242
  /**
243
243
  * Return every machine in any of the given zones — the discovery
244
244
  * query the aspect runner uses to find fan-out targets per
245
- * v2/CELILO_BASE.md.
245
+ * openspec/specs/base-module-aspects/spec.md.
246
246
  *
247
247
  * Filters:
248
248
  * - `excludeApiOnly` (default `true`): drops machines marked
249
249
  * `api_only` (the greenwave / ISP-modem case). Aspects use
250
- * Ansible, so api_only systems are unreachable. v2/CELILO_BASE.md
250
+ * Ansible, so api_only systems are unreachable. openspec/specs/base-module-aspects/spec.md
251
251
  * D8.
252
252
  * - `excludeHostnames` (default `[]`): drops named hostnames.
253
253
  * Aspect authors that need to skip the system running their own
254
254
  * primary deploy pass it here; the framework doesn't auto-skip
255
- * (v2/CELILO_BASE.md D3).
255
+ * (openspec/specs/base-module-aspects/spec.md D3).
256
256
  *
257
257
  * NOTE on container_service systems: this Phase 1 implementation
258
258
  * covers MACHINE-based systems only (the machines table). LXCs/VMs
@@ -39,6 +39,7 @@ import { E2E_CONFLICT_MESSAGE, runningE2eContainers } from './e2e-guard';
39
39
  import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
40
40
  import { findMachineForModule } from './machine-pool';
41
41
  import { checkProxmoxReachable, formatProxmoxUnreachableError } from './proxmox-preflight';
42
+ import { republishStaticWebConsumers } from './public-web-republish';
42
43
  import { deleteTemporarySshKey, writeTemporarySshKey } from './ssh-key-manager';
43
44
  import { buildTerraformEnvForService } from './terraform-env';
44
45
 
@@ -335,7 +336,7 @@ async function deployModuleImpl(
335
336
  // events and prompts via clack. Other responder shapes (Claude
336
337
  // subagent, `celilo events respond` from another shell, autoresponder
337
338
  // daemon) compete on the bus; first reply wins. See
338
- // infra/design/INTERACTIVE_DEPLOYS_VIA_BUS.md.
339
+ // infra/openspec/changes/interactive-deploys-via-event-bus/proposal.md.
339
340
  const terminalResponder = process.stdin.isTTY
340
341
  ? (await import('./terminal-responder')).startTerminalResponder()
341
342
  : null;
@@ -772,7 +773,7 @@ async function deployModuleImpl(
772
773
 
773
774
  // Fan out the module's base-module aspect (if any) per the
774
775
  // on_install trigger. Failures here don't fail the primary
775
- // deploy — D4 in v2/CELILO_BASE.md: aspects are idempotent
776
+ // deploy — D4 in openspec/specs/base-module-aspects/spec.md: aspects are idempotent
776
777
  // and forward-progress; a partial fleet update is expected to
777
778
  // converge on the next fan-out. We log the result instead.
778
779
  const aspectOutcome = await maybeRunAspectForTrigger({
@@ -789,6 +790,27 @@ async function deployModuleImpl(
789
790
  log.success(`Base-module aspect fan-out for '${moduleId}' completed`);
790
791
  }
791
792
 
793
+ // A public_web provider deploy rebuilds its host and takes /srv/www with
794
+ // it, while consumers' route rows survive — so the fresh provider would
795
+ // render site blocks pointing at directories that no longer exist. Re-run
796
+ // each static consumer's on_install to refill them. Idempotent; failures
797
+ // are logged, never fatal to the provider's own deploy.
798
+ const republish = await republishStaticWebConsumers({
799
+ providerModuleId: moduleId,
800
+ manifest,
801
+ db,
802
+ });
803
+ if (republish.republished.length > 0) {
804
+ log.success(
805
+ `Re-published ${republish.republished.length} static site(s) to '${moduleId}': ${republish.republished.join(', ')}`,
806
+ );
807
+ }
808
+ for (const failure of republish.failures) {
809
+ log.warn(
810
+ `Failed to re-publish '${failure.moduleId}' to '${moduleId}': ${failure.error}. Run \`celilo module deploy ${failure.moduleId}\` to restore its content.`,
811
+ );
812
+ }
813
+
792
814
  // Mirror the infrastructure-path success message at the end of
793
815
  // a successful deploy. Without this, config-only deploys end
794
816
  // abruptly with whatever the last hook line was — operator sees
@@ -884,7 +906,7 @@ async function deployModuleImpl(
884
906
  // Record the module's deployed system(s) now that the IP is known for every
885
907
  // provider type (machine / proxmox IPAM / DO outputs). This populates
886
908
  // ctx.systems for on_install and is the source of truth for system.created
887
- // and DNS (v2/MODULE_SYSTEMS_ADDRESSING.md). API-only modules record none.
909
+ // and DNS (openspec/specs/module-systems-addressing/spec.md). API-only modules record none.
888
910
  {
889
911
  const { recordDeployedSystemForModule } = await import('./deployed-systems');
890
912
  const recorded = await recordDeployedSystemForModule(
@@ -1170,7 +1192,7 @@ async function deployModuleImpl(
1170
1192
 
1171
1193
  // Inject the machine's IP as ip.primary for hooks that still read it
1172
1194
  // (firewall NAT target, etc.). The host's address for hooks now comes
1173
- // from ctx.systems (v2/MODULE_SYSTEMS_ADDRESSING.md), recorded into
1195
+ // from ctx.systems (openspec/specs/module-systems-addressing/spec.md), recorded into
1174
1196
  // module_systems during generate — no target_ip is written here.
1175
1197
  if (machineId) {
1176
1198
  const { getMachine } = await import('./machine-pool');
@@ -1235,6 +1257,48 @@ async function deployModuleImpl(
1235
1257
  error: installResult.error,
1236
1258
  };
1237
1259
  }
1260
+
1261
+ // Persist on_install string outputs as secrets on this module — same
1262
+ // as the validate_config / container_created paths above. caddy relies
1263
+ // on this to publish its firewall external IP (`public_ip`) so
1264
+ // public_web.register_route can point a new hostname's public A record
1265
+ // at it (M1 / #328).
1266
+ if (Object.keys(installResult.outputs).length > 0) {
1267
+ const { secrets: st } = await import('../db/schema');
1268
+ const { encryptSecret } = await import('../secrets/encryption');
1269
+ const mk = await getOrCreateMasterKey();
1270
+ for (const [key, value] of Object.entries(installResult.outputs)) {
1271
+ if (typeof value !== 'string') continue;
1272
+ const encrypted = encryptSecret(value, mk);
1273
+ const existing = db
1274
+ .select()
1275
+ .from(st)
1276
+ .where(and(eq(st.moduleId, moduleId), eq(st.name, key)))
1277
+ .get();
1278
+ if (existing) {
1279
+ db.update(st)
1280
+ .set({
1281
+ encryptedValue: encrypted.encryptedValue,
1282
+ iv: encrypted.iv,
1283
+ authTag: encrypted.authTag,
1284
+ updatedAt: new Date(),
1285
+ })
1286
+ .where(eq(st.id, existing.id))
1287
+ .run();
1288
+ } else {
1289
+ db.insert(st)
1290
+ .values({
1291
+ moduleId,
1292
+ name: key,
1293
+ encryptedValue: encrypted.encryptedValue,
1294
+ iv: encrypted.iv,
1295
+ authTag: encrypted.authTag,
1296
+ })
1297
+ .run();
1298
+ }
1299
+ log.success(`Stored hook output: ${key}`);
1300
+ }
1301
+ }
1238
1302
  }
1239
1303
 
1240
1304
  // Transition to INSTALLED
@@ -1274,7 +1338,7 @@ async function deployModuleImpl(
1274
1338
  // host — the event path below only covers systems that deploy AFTER the
1275
1339
  // provider (deliveries bind at emit time). Non-providers skip this
1276
1340
  // entirely; their registration rides the system.created event below.
1277
- // v2/EVENT_DRIVEN_HOOK_SUBSCRIPTIONS.md.
1341
+ // openspec/specs/event-driven-hook-subscriptions/spec.md.
1278
1342
  const { isDnsInternalProvider, backfillProviderDns, backfillWebRouteDns } = await import(
1279
1343
  './dns-provider-backfill'
1280
1344
  );
@@ -1304,8 +1368,8 @@ async function deployModuleImpl(
1304
1368
 
1305
1369
  // Announce each deployed system on the bus (D5/D6) — one
1306
1370
  // system.created.<module> per host, so a dns_internal provider's
1307
- // subscription registers every one (v2/MODULE_SYSTEMS_ADDRESSING.md,
1308
- // v2/INTERNAL_DNS_DHCP_AND_SPLIT_HORIZON.md, v2/EVENT_DRIVEN_HOOK_SUBSCRIPTIONS.md).
1371
+ // subscription registers every one (openspec/specs/module-systems-addressing/spec.md,
1372
+ // openspec/specs/internal-dns-split-horizon/spec.md, openspec/specs/event-driven-hook-subscriptions/spec.md).
1309
1373
  try {
1310
1374
  const { emitSystemCreated } = await import('./celilo-events');
1311
1375
  for (const sys of getModuleSystems(moduleId, db)) {
@@ -1323,7 +1387,7 @@ async function deployModuleImpl(
1323
1387
 
1324
1388
  // Fan out the module's base-module aspect (if any) per the
1325
1389
  // on_install trigger. Failures here don't fail the primary
1326
- // deploy — D4 in v2/CELILO_BASE.md: aspects are idempotent
1390
+ // deploy — D4 in openspec/specs/base-module-aspects/spec.md: aspects are idempotent
1327
1391
  // and forward-progress; a partial fleet update is expected to
1328
1392
  // converge on the next fan-out. We log the result instead.
1329
1393
  const aspectOutcome = await maybeRunAspectForTrigger({
@@ -1340,6 +1404,27 @@ async function deployModuleImpl(
1340
1404
  log.success(`Base-module aspect fan-out for '${moduleId}' completed`);
1341
1405
  }
1342
1406
 
1407
+ // A public_web provider deploy rebuilds its host and takes /srv/www with
1408
+ // it, while consumers' route rows survive — so the fresh provider would
1409
+ // render site blocks pointing at directories that no longer exist. Re-run
1410
+ // each static consumer's on_install to refill them. Idempotent; failures
1411
+ // are logged, never fatal to the provider's own deploy.
1412
+ const republish = await republishStaticWebConsumers({
1413
+ providerModuleId: moduleId,
1414
+ manifest,
1415
+ db,
1416
+ });
1417
+ if (republish.republished.length > 0) {
1418
+ log.success(
1419
+ `Re-published ${republish.republished.length} static site(s) to '${moduleId}': ${republish.republished.join(', ')}`,
1420
+ );
1421
+ }
1422
+ for (const failure of republish.failures) {
1423
+ log.warn(
1424
+ `Failed to re-publish '${failure.moduleId}' to '${moduleId}': ${failure.error}. Run \`celilo module deploy ${failure.moduleId}\` to restore its content.`,
1425
+ );
1426
+ }
1427
+
1343
1428
  log.success(`Module '${moduleId}' deployed successfully`);
1344
1429
  return {
1345
1430
  success: true,
@@ -7,7 +7,7 @@
7
7
  * - `$self` in `pattern` → the module's id
8
8
  * - `${MODULE_PATH}` in `handler` → the module's installed targetPath
9
9
  * - a `hook:` subscription → a synthesized `celilo events run-hook
10
- * <module> <sub-name>` handler (v2/EVENT_DRIVEN_HOOK_SUBSCRIPTIONS.md)
10
+ * <module> <sub-name>` handler (openspec/specs/event-driven-hook-subscriptions/spec.md)
11
11
  *
12
12
  * The bus subscriber's name is namespaced as `<module-id>.<sub-name>`
13
13
  * so two modules can declare a subscription named `smoke` without
@@ -11,7 +11,7 @@
11
11
  * output files; this module is responsible for the translation only, so
12
12
  * unit tests can exercise every branch without touching the filesystem.
13
13
  *
14
- * See `design/TECHNICAL_DESIGN_HOOK_API_V2.md` D2 for the design rationale.
14
+ * See `openspec/changes/hook-api-v2/proposal.md` D2 for the design rationale.
15
15
  */
16
16
 
17
17
  import type { ModuleManifest, VariableDeclare, VariableImport } from '../manifest/schema';
@@ -94,7 +94,7 @@ export function checkGitHygiene(modulePath: string, versionSourceKind?: string):
94
94
  // `celilo module version` and ordered by the +N revision, so source moving
95
95
  // past the manifest is the NORMAL case, not drift; for pin modules the version
96
96
  // is checked against the upstream resolver, not git ancestry. Skip the gate for
97
- // both (v2/MODULE_VERSIONING.md / ISS-0151).
97
+ // both (openspec/changes/module-version-semantics/proposal.md / ISS-0151).
98
98
  if (versionSourceKind === 'changeset' || versionSourceKind === 'pin') {
99
99
  checks.push({
100
100
  category: 'git_hygiene',
@@ -0,0 +1,92 @@
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 { PortForwardStore } from '@celilo/capabilities';
6
+ import type { DbClient } from '../db/client';
7
+ import { setupTestDatabase } from '../test-utils/setup-test-db';
8
+ import { buildPortForwardStore } from './port-forwards';
9
+
10
+ const FW = '192.168.0.254';
11
+
12
+ describe('port-forward store', () => {
13
+ let dir: string;
14
+ let db: DbClient;
15
+ let store: PortForwardStore;
16
+
17
+ beforeEach(async () => {
18
+ dir = mkdtempSync(join(tmpdir(), 'pf-'));
19
+ const dbPath = join(dir, 'celilo.db');
20
+ process.env.CELILO_DB_PATH = dbPath;
21
+ db = await setupTestDatabase(dbPath);
22
+ store = buildPortForwardStore(db);
23
+ });
24
+ afterEach(() => {
25
+ db.$client.close();
26
+ process.env.CELILO_DB_PATH = undefined;
27
+ try {
28
+ rmSync(dir, { recursive: true, force: true });
29
+ } catch {
30
+ /* ignore */
31
+ }
32
+ });
33
+
34
+ it('adds and lists forwards for a firewall', () => {
35
+ store.add(FW, { internalIp: '10.0.20.5', port: 443, protocol: 'TCP', description: 'caddy' });
36
+ store.add(FW, {
37
+ internalIp: '10.0.20.42',
38
+ port: 2222,
39
+ protocol: 'TCP',
40
+ description: 'forgejo',
41
+ });
42
+ const forwards = store.list(FW);
43
+ expect(forwards).toHaveLength(2);
44
+ expect(forwards.map((f) => f.port).sort((a, b) => a - b)).toEqual([443, 2222]);
45
+ });
46
+
47
+ it('add is an idempotent upsert — same tuple → one row, description updated', () => {
48
+ store.add(FW, { internalIp: '10.0.20.5', port: 443, protocol: 'TCP', description: 'v1' });
49
+ store.add(FW, { internalIp: '10.0.20.5', port: 443, protocol: 'TCP', description: 'v2' });
50
+ const forwards = store.list(FW);
51
+ expect(forwards).toHaveLength(1);
52
+ expect(forwards[0].description).toBe('v2');
53
+ });
54
+
55
+ it('scopes forwards by firewallIp', () => {
56
+ store.add(FW, { internalIp: '10.0.20.5', port: 443, protocol: 'TCP', description: 'a' });
57
+ store.add('10.0.30.254', {
58
+ internalIp: '10.0.30.5',
59
+ port: 53,
60
+ protocol: 'UDP',
61
+ description: 'b',
62
+ });
63
+ expect(store.list(FW)).toHaveLength(1);
64
+ expect(store.list('10.0.30.254')).toHaveLength(1);
65
+ });
66
+
67
+ it('remove deletes exactly the tuple and leaves others', () => {
68
+ store.add(FW, { internalIp: '10.0.20.5', port: 443, protocol: 'TCP', description: 'x' });
69
+ store.add(FW, { internalIp: '10.0.20.5', port: 80, protocol: 'TCP', description: 'y' });
70
+ store.remove(FW, '10.0.20.5', 443, 'TCP');
71
+ const forwards = store.list(FW);
72
+ expect(forwards).toHaveLength(1);
73
+ expect(forwards[0].port).toBe(80);
74
+ });
75
+
76
+ it('ingressIp forwards are distinct from the public (NULL) ones', () => {
77
+ store.add(FW, { internalIp: '10.0.20.5', port: 53, protocol: 'UDP', description: 'public' });
78
+ store.add(FW, {
79
+ internalIp: '10.0.20.5',
80
+ port: 53,
81
+ protocol: 'UDP',
82
+ ingressIp: '10.0.10.53',
83
+ description: 'ingress',
84
+ });
85
+ expect(store.list(FW)).toHaveLength(2);
86
+ // removing the public (NULL-ingress) one leaves the ingress one intact
87
+ store.remove(FW, '10.0.20.5', 53, 'UDP');
88
+ const forwards = store.list(FW);
89
+ expect(forwards).toHaveLength(1);
90
+ expect(forwards[0].ingressIp).toBe('10.0.10.53');
91
+ });
92
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Port-forward registry — the DB-backed `PortForwardStore` (openspec/changes/unified-management-no-ssh/proposal.md).
3
+ *
4
+ * The shared-core desired-state store for the `firewall` capability. The
5
+ * capability-loader constructs one of these and injects it into the firewall
6
+ * provider factory, so `exposeService`/`unexposeService` become register/
7
+ * unregister and the provider's converge renders the complete ruleset from
8
+ * `list(firewallIp)` and applies it atomically (`iptables-restore`). Replaces
9
+ * "read the box back with `iptables -L`" as the source of truth.
10
+ */
11
+
12
+ import type { PortForward, PortForwardStore, Protocol } from '@celilo/capabilities';
13
+ import { and, eq, isNull } from 'drizzle-orm';
14
+ import type { DbClient } from '../db/client';
15
+ import { portForwards } from '../db/schema';
16
+
17
+ /** NULL-aware match on the optional ingress IP (SQLite treats NULL as distinct). */
18
+ function ingressMatch(ingressIp: string | undefined) {
19
+ return ingressIp ? eq(portForwards.ingressIp, ingressIp) : isNull(portForwards.ingressIp);
20
+ }
21
+
22
+ export function buildPortForwardStore(db: DbClient): PortForwardStore {
23
+ return {
24
+ list(firewallIp: string): PortForward[] {
25
+ return db
26
+ .select()
27
+ .from(portForwards)
28
+ .where(eq(portForwards.firewallIp, firewallIp))
29
+ .all()
30
+ .map((r) => ({
31
+ internalIp: r.internalIp,
32
+ port: r.port,
33
+ protocol: r.protocol,
34
+ ingressIp: r.ingressIp ?? undefined,
35
+ description: r.description,
36
+ }));
37
+ },
38
+
39
+ add(firewallIp: string, forward: PortForward): void {
40
+ // Idempotent upsert: delete any existing row for this exact tuple, then
41
+ // insert. Matches buildRouteOps' delete-then-insert (keeps the operation
42
+ // atomic w.r.t. the unique index, and NULL-aware on ingress_ip).
43
+ db.delete(portForwards)
44
+ .where(
45
+ and(
46
+ eq(portForwards.firewallIp, firewallIp),
47
+ eq(portForwards.internalIp, forward.internalIp),
48
+ eq(portForwards.port, forward.port),
49
+ eq(portForwards.protocol, forward.protocol),
50
+ ingressMatch(forward.ingressIp),
51
+ ),
52
+ )
53
+ .run();
54
+ db.insert(portForwards)
55
+ .values({
56
+ firewallIp,
57
+ internalIp: forward.internalIp,
58
+ port: forward.port,
59
+ protocol: forward.protocol,
60
+ ingressIp: forward.ingressIp ?? null,
61
+ description: forward.description,
62
+ })
63
+ .run();
64
+ },
65
+
66
+ remove(
67
+ firewallIp: string,
68
+ internalIp: string,
69
+ port: number,
70
+ protocol: Protocol,
71
+ ingressIp?: string,
72
+ ): void {
73
+ db.delete(portForwards)
74
+ .where(
75
+ and(
76
+ eq(portForwards.firewallIp, firewallIp),
77
+ eq(portForwards.internalIp, internalIp),
78
+ eq(portForwards.port, port),
79
+ eq(portForwards.protocol, protocol),
80
+ ingressMatch(ingressIp),
81
+ ),
82
+ )
83
+ .run();
84
+ },
85
+ };
86
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Proxmox reconciliation (v2/CELILO_BASE.md D5).
2
+ * Proxmox reconciliation (openspec/specs/base-module-aspects/spec.md D5).
3
3
  *
4
4
  * When a base-module aspect declares `proxmox_reconcile.tfvars` and
5
5
  * its fan-out plan touches Proxmox-provisioned LXCs, the running
@@ -151,6 +151,6 @@ export function executeProxmoxReconcile(plan: ProxmoxReconcilePlan): void {
151
151
  );
152
152
  }
153
153
  log.warn(
154
- 'To persist these values, re-deploy the owning modules after the underlying celilo state catches up. Tracked as Phase 1c in v2/CELILO_BASE.md.',
154
+ 'To persist these values, re-deploy the owning modules after the underlying celilo state catches up. Tracked as Phase 1c in openspec/specs/base-module-aspects/spec.md.',
155
155
  );
156
156
  }