@celilo/cli 0.11.0-alpha.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 (121) hide show
  1. package/AGENTS.md +7 -4
  2. package/CELILO_CORE_MODULES.md +7 -6
  3. package/CELILO_SUBSYSTEMS.md +47 -5
  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 +8 -8
  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/capabilities/public-web-publish.test.ts +209 -0
  13. package/src/capabilities/validation.test.ts +1 -1
  14. package/src/capabilities/well-known.test.ts +1 -1
  15. package/src/capabilities/well-known.ts +2 -2
  16. package/src/cli/commands/api.ts +1 -1
  17. package/src/cli/commands/backup-pull.ts +1 -1
  18. package/src/cli/commands/events.ts +1 -1
  19. package/src/cli/commands/module-changeset.ts +1 -1
  20. package/src/cli/commands/module-import.ts +1 -1
  21. package/src/cli/commands/module-publish.ts +1 -1
  22. package/src/cli/commands/module-remove.ts +26 -1
  23. package/src/cli/commands/module-types.ts +1 -1
  24. package/src/cli/commands/module-upgrade.ts +1 -1
  25. package/src/cli/commands/module-version.ts +1 -1
  26. package/src/cli/commands/module-where.test.ts +26 -0
  27. package/src/cli/commands/module-where.ts +130 -0
  28. package/src/cli/commands/proxmox-node-list.ts +1 -1
  29. package/src/cli/commands/publish/alpha.test.ts +1 -1
  30. package/src/cli/commands/publish/alpha.ts +3 -3
  31. package/src/cli/commands/publish/changesets.ts +1 -1
  32. package/src/cli/commands/publish/global-install.ts +6 -2
  33. package/src/cli/commands/publish/helpers.ts +21 -14
  34. package/src/cli/commands/publish/index.ts +4 -4
  35. package/src/cli/commands/publish/plan.ts +3 -3
  36. package/src/cli/commands/publish/preflight.ts +2 -2
  37. package/src/cli/commands/publish/types.ts +2 -2
  38. package/src/cli/commands/publish/workspace.test.ts +1 -1
  39. package/src/cli/commands/publish/workspace.ts +2 -2
  40. package/src/cli/commands/registry-owner.test.ts +166 -0
  41. package/src/cli/commands/registry-owner.ts +124 -0
  42. package/src/cli/commands/registry-token.test.ts +109 -0
  43. package/src/cli/commands/registry-token.ts +194 -0
  44. package/src/cli/commands/restore.ts +1 -1
  45. package/src/cli/commands/service-add-proxmox.ts +1 -1
  46. package/src/cli/commands/subscribers-list.ts +1 -1
  47. package/src/cli/commands/system-apply-config-equivalence.test.ts +1 -1
  48. package/src/cli/commands/system-apply-config.ts +1 -1
  49. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  50. package/src/cli/commands/system-init.ts +2 -2
  51. package/src/cli/commands/token.test.ts +26 -0
  52. package/src/cli/commands/token.ts +160 -0
  53. package/src/cli/completion.ts +19 -0
  54. package/src/cli/index.ts +155 -4
  55. package/src/cli/restore-command.test.ts +1 -1
  56. package/src/db/client.ts +1 -1
  57. package/src/db/schema.ts +50 -7
  58. package/src/hooks/capability-loader-firewall.test.ts +4 -3
  59. package/src/hooks/capability-loader.ts +100 -10
  60. package/src/hooks/define-hook.test.ts +24 -0
  61. package/src/hooks/executor.test.ts +1 -1
  62. package/src/hooks/executor.ts +2 -2
  63. package/src/hooks/types.ts +1 -1
  64. package/src/manifest/contracts/v1.ts +2 -2
  65. package/src/manifest/schema.ts +13 -13
  66. package/src/manifest/template-validator.ts +1 -1
  67. package/src/module/packaging/build.ts +19 -0
  68. package/src/module/packaging/workspace-deps.test.ts +94 -0
  69. package/src/module/packaging/workspace-deps.ts +185 -0
  70. package/src/module/versioning/changeset-version.ts +1 -1
  71. package/src/policy/no-hand-built-ssh.test.ts +90 -0
  72. package/src/registry/client.test.ts +86 -0
  73. package/src/registry/client.ts +67 -1
  74. package/src/services/api-access.ts +1 -1
  75. package/src/services/aspect-approvals.ts +1 -1
  76. package/src/services/aspect-runner.ts +3 -3
  77. package/src/services/aspect-template-resolver.test.ts +1 -1
  78. package/src/services/aspect-template-resolver.ts +1 -1
  79. package/src/services/build-bus/delivery-events.ts +1 -1
  80. package/src/services/build-bus/fan-out.ts +1 -1
  81. package/src/services/build-bus/hook-dispatch.ts +1 -1
  82. package/src/services/build-bus/receiver-server.ts +1 -1
  83. package/src/services/build-bus/status.test.ts +1 -1
  84. package/src/services/build-bus/status.ts +1 -1
  85. package/src/services/build-bus/subscriber-store.ts +1 -1
  86. package/src/services/bus-interview.ts +1 -1
  87. package/src/services/celilo-events.ts +1 -1
  88. package/src/services/celilo-mgmt-hooks.test.ts +1 -1
  89. package/src/services/cross-module-read.ts +1 -1
  90. package/src/services/deploy-posture.ts +1 -1
  91. package/src/services/deployed-systems.test.ts +1 -1
  92. package/src/services/deployed-systems.ts +4 -4
  93. package/src/services/dns-provider-backfill.ts +2 -2
  94. package/src/services/events-daemon.ts +1 -1
  95. package/src/services/machine-pool.ts +3 -3
  96. package/src/services/module-deploy.ts +93 -8
  97. package/src/services/module-subscriptions.ts +1 -1
  98. package/src/services/module-types-generator.ts +1 -1
  99. package/src/services/module-validator/git-hygiene.ts +1 -1
  100. package/src/services/port-forwards.test.ts +92 -0
  101. package/src/services/port-forwards.ts +86 -0
  102. package/src/services/proxmox-reconcile.ts +2 -2
  103. package/src/services/public-web-republish.test.ts +189 -0
  104. package/src/services/public-web-republish.ts +84 -0
  105. package/src/services/restore-from-file.ts +1 -1
  106. package/src/services/restore-preflight.ts +1 -1
  107. package/src/services/system-identity.ts +1 -1
  108. package/src/services/system-init.test.ts +2 -2
  109. package/src/services/system-init.ts +2 -2
  110. package/src/services/web-route-cleanup.test.ts +250 -0
  111. package/src/services/web-route-cleanup.ts +144 -0
  112. package/src/templates/generator.ts +2 -2
  113. package/src/types/infrastructure.ts +1 -1
  114. package/src/variables/computed/computed-integration.test.ts +1 -1
  115. package/src/variables/computed/evaluate.ts +1 -1
  116. package/src/variables/computed/parse.ts +1 -1
  117. package/src/variables/context.test.ts +1 -1
  118. package/src/variables/context.ts +5 -5
  119. package/src/variables/lxc-nameserver.test.ts +1 -1
  120. package/src/variables/resolver.ts +2 -2
  121. package/src/variables/types.ts +1 -1
@@ -40,6 +40,14 @@ export interface ModuleMetadata {
40
40
  versions: Array<{ num: string; yanked: boolean; created_at: string }>;
41
41
  }
42
42
 
43
+ /** A module-owner table entry (ce-1ch). Mirrors the registry-server shape. */
44
+ export interface ModuleOwner {
45
+ moduleName: string;
46
+ ownerSub: string;
47
+ claimedAt: string;
48
+ sourceGroup: string;
49
+ }
50
+
43
51
  export class RegistryClient {
44
52
  readonly baseUrl: string;
45
53
 
@@ -158,10 +166,68 @@ export class RegistryClient {
158
166
  return resp.json() as Promise<{ ok: boolean; name: string; vers: string }>;
159
167
  }
160
168
 
169
+ // ── Module ownership (admin — ce-1ch) ──────────────────────────────────────
170
+
171
+ /** List the whole module-owner table. Requires an admin token. */
172
+ async listOwners(token: string): Promise<ModuleOwner[]> {
173
+ const resp = await this.authed(`${this.baseUrl}/api/v1/modules/owners`, token);
174
+ const body = (await resp.json()) as { owners: ModuleOwner[] };
175
+ return body.owners;
176
+ }
177
+
178
+ /** Show the owner of one module name, or null if unclaimed. Requires an admin token. */
179
+ async getOwner(name: string, token: string): Promise<ModuleOwner | null> {
180
+ const url = `${this.baseUrl}/api/v1/modules/owners/${encodeURIComponent(name)}`;
181
+ const resp = await fetch(url, {
182
+ headers: { Authorization: token },
183
+ signal: AbortSignal.timeout(30_000),
184
+ });
185
+ if (resp.status === 404) return null;
186
+ if (!resp.ok) throw new Error(await this.errorDetail(resp));
187
+ const body = (await resp.json()) as { owner: ModuleOwner };
188
+ return body.owner;
189
+ }
190
+
191
+ /** Reassign a module name to `ownerSub` (admin overwrite). Requires an admin token. */
192
+ async setOwner(name: string, ownerSub: string, token: string): Promise<ModuleOwner> {
193
+ const url = `${this.baseUrl}/api/v1/modules/owners/${encodeURIComponent(name)}`;
194
+ const resp = await fetch(url, {
195
+ method: 'POST',
196
+ headers: { Authorization: token, 'Content-Type': 'application/json' },
197
+ body: JSON.stringify({ ownerSub }),
198
+ signal: AbortSignal.timeout(30_000),
199
+ });
200
+ if (!resp.ok) throw new Error(await this.errorDetail(resp));
201
+ const body = (await resp.json()) as { owner: ModuleOwner };
202
+ return body.owner;
203
+ }
204
+
161
205
  // ── Internal ──────────────────────────────────────────────────────────────
162
206
 
207
+ /** GET with an Authorization header, throwing the server's error detail on failure. */
208
+ private async authed(url: string, token: string): Promise<Response> {
209
+ const resp = await fetch(url, {
210
+ headers: { Authorization: token },
211
+ signal: AbortSignal.timeout(30_000),
212
+ });
213
+ if (!resp.ok) throw new Error(await this.errorDetail(resp));
214
+ return resp;
215
+ }
216
+
217
+ /** Extract `errors[0].detail` from a Cargo-protocol error body, or fall back to the status. */
218
+ private async errorDetail(resp: Response): Promise<string> {
219
+ const body = (await resp.json().catch(() => ({}))) as {
220
+ errors?: Array<{ detail: string }>;
221
+ };
222
+ return body.errors?.[0]?.detail ?? `HTTP ${resp.status}`;
223
+ }
224
+
163
225
  private async get(url: string): Promise<Response> {
164
- const resp = await fetch(url, { signal: AbortSignal.timeout(15_000) });
226
+ // 30s (was 15s): a module .netapp can be tens of MB and the download must
227
+ // finish within one window. 15s was too aggressive on slow links — the
228
+ // download of a ~19MB module over a multi-hop path (e2e sim NAT, real WAN)
229
+ // intermittently timed out mid-transfer.
230
+ const resp = await fetch(url, { signal: AbortSignal.timeout(30_000) });
165
231
  if (!resp.ok) {
166
232
  throw new Error(`Registry error at ${url}: HTTP ${resp.status}`);
167
233
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Remote API access control (see v2/API_COMMUNICATION.md, Slice 2a).
2
+ * Remote API access control (see openspec/changes/replace-ssh-cli-api/proposal.md, Slice 2a).
3
3
  *
4
4
  * Stores API principals (name + SSH public key + grants), answers authz
5
5
  * queries (deny-by-default), and renders the API account's `authorized_keys`
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Aspect approvals — operator consent for a module's base-module aspect.
3
3
  *
4
- * Per v2/CELILO_BASE.md D2: when an operator imports a module that
4
+ * Per openspec/specs/base-module-aspects/spec.md D2: when an operator imports a module that
5
5
  * declares a `base_module_aspect`, they consent to that aspect's
6
6
  * scope (applicable_zones + triggers) ONCE at import time. The
7
7
  * consent is recorded in the `aspect_approvals` table and consulted
@@ -2,7 +2,7 @@
2
2
  * Aspect runner — executes a module's base-module aspect across
3
3
  * the fleet.
4
4
  *
5
- * Per v2/CELILO_BASE.md Phase 1: when a module with an approved
5
+ * Per openspec/specs/base-module-aspects/spec.md Phase 1: when a module with an approved
6
6
  * `base_module_aspect` triggers a fan-out event, the runner walks
7
7
  * every non-`api_only` system in the aspect's `applicable_zones`,
8
8
  * materializes an Ansible inventory + playbook in a scratch
@@ -97,7 +97,7 @@ export interface AspectRunOptions {
97
97
  /**
98
98
  * Hostnames to exclude from fan-out, on top of `api_only`. The
99
99
  * primary deploy's own host(s) typically don't need the aspect
100
- * (per v2/CELILO_BASE.md D3 — aspect authors handle this when
100
+ * (per openspec/specs/base-module-aspects/spec.md D3 — aspect authors handle this when
101
101
  * they want it, the framework doesn't force a skip).
102
102
  */
103
103
  excludeHostnames?: string[];
@@ -301,7 +301,7 @@ export async function materializeAspectAnsible(args: {
301
301
  /**
302
302
  * Execution-phase function: orchestrates plan + materialize + run.
303
303
  *
304
- * Failure semantics (v2/CELILO_BASE.md D4): aspects are idempotent
304
+ * Failure semantics (openspec/specs/base-module-aspects/spec.md D4): aspects are idempotent
305
305
  * and forward-progress only. A failed fan-out is reported and the
306
306
  * partial state (some systems updated, others not) is preserved —
307
307
  * no rollback. The caller (deploy planner, in SC4) decides how to
@@ -14,7 +14,7 @@ const TEST_DB_PATH = './test-aspect-resolver.db';
14
14
  * resolver (separate from variables/resolver.ts); it previously handled
15
15
  * only $self:/$system:/$capability:, so knot's migrated
16
16
  * `knot_server_ip: $infra:main.ipv4_address` leaked into resolv.conf
17
- * unresolved and broke DNS on every fleet machine. v2/MODULE_SYSTEMS_ADDRESSING.md.
17
+ * unresolved and broke DNS on every fleet machine. openspec/specs/module-systems-addressing/spec.md.
18
18
  */
19
19
  function seedModuleWithSystem(db: DbClient, moduleId: string, ip: string): void {
20
20
  db.insert(modules)
@@ -59,7 +59,7 @@ export async function resolveAspectTemplate(
59
59
  });
60
60
 
61
61
  // $infra:<system-name>.<field> — the provider's deployed system, by name
62
- // (v2/MODULE_SYSTEMS_ADDRESSING.md). This is how an aspect references the
62
+ // (openspec/specs/module-systems-addressing/spec.md). This is how an aspect references the
63
63
  // provider's own host IP (e.g. knot's dns-client-config aspect sets every
64
64
  // fleet machine's nameserver to $infra:main.ipv4_address). Without this the
65
65
  // literal template string would leak into resolv.conf.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Local-bus event emitters for build-bus delivery outcomes.
3
3
  *
4
- * Phase 6 of [[v2/BUILD_BUS.md]] wants `celilo subscribers status`
4
+ * Phase 6 of [[openspec/changes/build-bus-poll-cd/proposal.md]] wants `celilo subscribers status`
5
5
  * to show last-delivery state + recent failures per subscriber. The
6
6
  * data source for that view is the local SQLite event bus — every
7
7
  * publisher-side fan-out call emits one `webhook.delivered` or
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Webhook fan-out for the build bus ([[v2/BUILD_BUS.md]] Phase 2).
2
+ * Webhook fan-out for the build bus ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 2).
3
3
  *
4
4
  * Given a PublishEvent and a subscriber list, this module:
5
5
  * 1. Filters subscribers whose match rule fires for the event.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Hook-dispatch planning + execution for the build bus
3
- * ([[v2/BUILD_BUS.md]] Phase 4).
3
+ * ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4).
4
4
  *
5
5
  * Pure: planHookDispatch(event, modules) → HookDispatchPlan[].
6
6
  * Decides which module hooks match the event without spawning
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * HTTP receiver daemon for the build bus
3
- * ([[v2/BUILD_BUS.md]] Phase 3).
3
+ * ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 3).
4
4
  *
5
5
  * Verifies signed webhook envelopes against a per-receiver shared
6
6
  * secret, then re-emits the contained PublishEvent onto the local
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Status-aggregator tests — Phase 6 of [[v2/BUILD_BUS.md]].
2
+ * Status-aggregator tests — Phase 6 of [[openspec/changes/build-bus-poll-cd/proposal.md]].
3
3
  *
4
4
  * The aggregator is pure: takes subscribers + delivery records,
5
5
  * returns per-subscriber summaries. The formatter is pure too.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Delivery-history aggregator for `celilo subscribers status`
3
- * ([[v2/BUILD_BUS.md]] Phase 6).
3
+ * ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 6).
4
4
  *
5
5
  * Reads recent `webhook.delivered` + `webhook.failed` events from
6
6
  * the local event bus, groups by subscriber URL, and produces a
@@ -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',