@celilo/cli 0.14.2 → 0.15.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 (56) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +18 -2
  3. package/drizzle/0018_drop_alert_policy_snapshot.sql +46 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +4 -4
  6. package/src/capabilities/public-web-publish.test.ts +15 -15
  7. package/src/cli/commands/alerts-list.ts +10 -0
  8. package/src/cli/commands/alerts-poll.ts +12 -6
  9. package/src/cli/commands/alerts-sweep.ts +22 -81
  10. package/src/cli/commands/backup-sweep.ts +65 -0
  11. package/src/cli/commands/module-operations.test.ts +93 -0
  12. package/src/cli/commands/module-operations.ts +134 -0
  13. package/src/cli/commands/module-upgrade.test.ts +32 -20
  14. package/src/cli/commands/module-upgrade.ts +37 -32
  15. package/src/cli/commands/monitor.ts +26 -6
  16. package/src/cli/commands/system-audit.ts +9 -68
  17. package/src/cli/completion.ts +18 -1
  18. package/src/cli/index.ts +11 -0
  19. package/src/db/schema.ts +5 -3
  20. package/src/hooks/capability-loader.ts +0 -12
  21. package/src/manifest/schema.ts +4 -1
  22. package/src/module/packaging/build.ts +4 -0
  23. package/src/services/alerting/builtin-source.ts +18 -51
  24. package/src/services/alerting/delivery-loop.test.ts +5 -1
  25. package/src/services/alerting/format.test.ts +0 -1
  26. package/src/services/alerting/inbound-poller.test.ts +44 -8
  27. package/src/services/alerting/inbound-poller.ts +65 -28
  28. package/src/services/alerting/notify-deps.ts +113 -0
  29. package/src/services/alerting/run-monitor.ts +0 -1
  30. package/src/services/alerting/store.test.ts +1 -1
  31. package/src/services/alerting/store.ts +0 -2
  32. package/src/services/alerting/sweep-runner.test.ts +11 -2
  33. package/src/services/alerting/sweep-runner.ts +14 -7
  34. package/src/services/audit/backup-source.ts +54 -0
  35. package/src/services/audit/backups.test.ts +7 -2
  36. package/src/services/audit/backups.ts +10 -18
  37. package/src/services/backup-cipher.test.ts +188 -0
  38. package/src/services/backup-cipher.ts +178 -0
  39. package/src/services/backup-create.ts +20 -30
  40. package/src/services/backup-envelope-roundtrip.test.ts +6 -26
  41. package/src/services/backup-restore.ts +10 -16
  42. package/src/services/backup-schedule.ts +35 -0
  43. package/src/services/backup-sweep.test.ts +148 -0
  44. package/src/services/backup-sweep.ts +124 -0
  45. package/src/services/deploy-posture.ts +15 -2
  46. package/src/services/machine-probe.test.ts +50 -0
  47. package/src/services/machine-probe.ts +73 -0
  48. package/src/services/module-operations.test.ts +67 -6
  49. package/src/services/module-operations.ts +69 -19
  50. package/src/services/module-subscriptions.test.ts +33 -2
  51. package/src/services/module-subscriptions.ts +10 -1
  52. package/src/services/module-validator/typescript-build.test.ts +20 -1
  53. package/src/services/module-validator/typescript-build.ts +9 -5
  54. package/src/services/restore-from-file.ts +6 -21
  55. package/src/templates/generator.test.ts +88 -0
  56. package/src/templates/generator.ts +119 -16
@@ -248,12 +248,51 @@ export function targetNodeFromTfState(state: {
248
248
  }
249
249
 
250
250
  /**
251
- * Read the node a module's container is currently deployed on, from its terraform
252
- * state file. This is celilo's authoritative record of placement (ISS-0090).
253
- * Returns null when no state exists yet (a first deploy) or it can't be read
254
- * the caller then falls back to the service `default_target_node`.
251
+ * The storage an existing container's root disk actually lives on, from
252
+ * terraform state. Prefers the LXC `rootfs.storage` attribute, falls back to the
253
+ * `<storage>:vm-<vmid>-disk-0` volume prefix, then to a VM's data `disk` block.
254
+ * Returns null when there's no such resource (e.g. an empty/fresh state).
255
255
  */
256
- async function readDeployedTargetNode(moduleId: string): Promise<string | null> {
256
+ export function storageFromTfState(state: {
257
+ resources?: Array<{
258
+ type?: string;
259
+ instances?: Array<{
260
+ attributes?: {
261
+ rootfs?:
262
+ | Array<{ storage?: string; volume?: string }>
263
+ | { storage?: string; volume?: string };
264
+ disk?: Array<{ storage?: string; type?: string }>;
265
+ };
266
+ }>;
267
+ }>;
268
+ }): string | null {
269
+ const guest = state.resources?.find(
270
+ (r) => r.type === 'proxmox_lxc' || r.type === 'proxmox_vm_qemu',
271
+ );
272
+ const attrs = guest?.instances?.[0]?.attributes;
273
+ if (!attrs) {
274
+ return null;
275
+ }
276
+ // The provider models `rootfs` as a single-element block list; older state
277
+ // shapes record it as a bare object.
278
+ const rootfs = Array.isArray(attrs.rootfs) ? attrs.rootfs[0] : attrs.rootfs;
279
+ if (rootfs?.storage) {
280
+ return rootfs.storage;
281
+ }
282
+ // volume format: "<storage>:vm-<vmid>-disk-0"
283
+ const volumeStorage = rootfs?.volume?.split(':')[0];
284
+ if (volumeStorage) {
285
+ return volumeStorage;
286
+ }
287
+ // VMs: the data disk carries the storage that matters (the cloudinit disk
288
+ // rides on the same storage — see the `disk` block ordering note in the
289
+ // vm templates).
290
+ const disks = attrs.disk ?? [];
291
+ return (disks.find((d) => d.type === 'disk') ?? disks[0])?.storage || null;
292
+ }
293
+
294
+ /** Parse a module's terraform state, or null when absent/unreadable. */
295
+ async function readTfState(moduleId: string): Promise<Record<string, unknown> | null> {
257
296
  const statePath = join(
258
297
  getModuleStoragePath(),
259
298
  moduleId,
@@ -265,12 +304,33 @@ async function readDeployedTargetNode(moduleId: string): Promise<string | null>
265
304
  return null;
266
305
  }
267
306
  try {
268
- return targetNodeFromTfState(JSON.parse(await readFile(statePath, 'utf-8')));
307
+ return JSON.parse(await readFile(statePath, 'utf-8'));
269
308
  } catch {
270
309
  return null;
271
310
  }
272
311
  }
273
312
 
313
+ /**
314
+ * Read the node a module's container is currently deployed on, from its terraform
315
+ * state file. This is celilo's authoritative record of placement (ISS-0090).
316
+ * Returns null when no state exists yet (a first deploy) or it can't be read —
317
+ * the caller then falls back to the service `default_target_node`.
318
+ */
319
+ async function readDeployedTargetNode(moduleId: string): Promise<string | null> {
320
+ const state = await readTfState(moduleId);
321
+ return state ? targetNodeFromTfState(state) : null;
322
+ }
323
+
324
+ /**
325
+ * Read the storage a module's container is currently deployed on, from its
326
+ * terraform state file. Returns null on a first deploy — the caller then falls
327
+ * back to the service default storage.
328
+ */
329
+ async function readDeployedStorage(moduleId: string): Promise<string | null> {
330
+ const state = await readTfState(moduleId);
331
+ return state ? storageFromTfState(state) : null;
332
+ }
333
+
274
334
  /**
275
335
  * The node a module's container ACTUALLY lives on, from Proxmox (ISS-0090).
276
336
  * Proxmox is the ultimate source of truth for current location — it sees a
@@ -314,6 +374,27 @@ export function decideTargetNode(opts: {
314
374
  return { node: opts.defaultNode, source: 'default' };
315
375
  }
316
376
 
377
+ /**
378
+ * Decide which storage to target for a deploy. Pure (Rule 10). The sibling of
379
+ * `decideTargetNode`: the service's `storage` governs only a FIRST placement.
380
+ * A container already living on `local-lvm` must keep living there when the
381
+ * service default later changes to `datacenter` — emitting the new default
382
+ * makes terraform plan a destroy-and-recreate ("forces replacement") of a
383
+ * running container and lose its volume. Relocating storage is a deliberate
384
+ * migration, never a side effect of a default drifting.
385
+ *
386
+ * ponytail: terraform state only, no Proxmox-reality tier like decideTargetNode
387
+ * has. A hand `pct move-volume` is invisible until the next apply refreshes
388
+ * state; add a Proxmox read if that ever bites.
389
+ */
390
+ export function decideStorage(opts: {
391
+ stateStorage: string | null;
392
+ defaultStorage: string;
393
+ }): { storage: string; source: 'state' | 'default' } {
394
+ if (opts.stateStorage) return { storage: opts.stateStorage, source: 'state' };
395
+ return { storage: opts.defaultStorage, source: 'default' };
396
+ }
397
+
317
398
  /**
318
399
  * Discover template files in directory recursively
319
400
  *
@@ -784,6 +865,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
784
865
  // Resolved live each generate (ISS-0090) and injected into the context below,
785
866
  // never cached in the DB. undefined for non-Proxmox / machine deploys.
786
867
  let resolvedTargetNode: string | undefined;
868
+ let resolvedStorage: string | undefined;
787
869
  if (isContainerService && isProxmoxService && infrastructureSelection?.serviceId) {
788
870
  const service = await db
789
871
  .select()
@@ -824,11 +906,24 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
824
906
  );
825
907
  }
826
908
 
827
- // Persist only the non-drift provider values. target_node is reality it's
828
- // injected into the resolution context below, never cached (ISS-0090); drop
829
- // any stale __infra_target_node a prior generate left behind.
909
+ // Storage is sticky for the same reason placement is: the service default
910
+ // governs only a FIRST create. Emitting a changed default at an existing
911
+ // container makes terraform plan a destroy-and-recreate.
912
+ const storageDecision = decideStorage({
913
+ stateStorage: await readDeployedStorage(moduleId),
914
+ defaultStorage: providerConfig.storage,
915
+ });
916
+ resolvedStorage = storageDecision.storage;
917
+ if (storageDecision.source === 'state' && resolvedStorage !== providerConfig.storage) {
918
+ log.info(
919
+ `${moduleId} → storage '${resolvedStorage}' (from terraform state; service default is '${providerConfig.storage}'). Moving storage requires a deliberate migration.`,
920
+ );
921
+ }
922
+
923
+ // Persist only the non-drift provider values. target_node and storage are
924
+ // reality — they're injected into the resolution context below, never
925
+ // cached (ISS-0090); drop any stale __infra_* rows a prior generate left.
830
926
  upsertModuleConfig(db, moduleId, '__infra_lxc_template', providerConfig.lxc_template);
831
- upsertModuleConfig(db, moduleId, '__infra_storage', providerConfig.storage);
832
927
  // vm_template is only present once a VM template exists for the service.
833
928
  // Persist it when set so `type: vm` modules can resolve $self:vm_template;
834
929
  // a `type: vm` deploy against a service without one then fails loudly at
@@ -837,8 +932,12 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
837
932
  upsertModuleConfig(db, moduleId, '__infra_vm_template', providerConfig.vm_template);
838
933
  }
839
934
  deleteModuleConfig(db, moduleId, '__infra_target_node');
935
+ deleteModuleConfig(db, moduleId, '__infra_storage');
840
936
 
841
- log.success(`Infrastructure resolved: target_node=${decision.node} (${decision.source})`);
937
+ log.success(
938
+ `Infrastructure resolved: target_node=${decision.node} (${decision.source}), ` +
939
+ `storage=${storageDecision.storage} (${storageDecision.source})`,
940
+ );
842
941
  }
843
942
  }
844
943
 
@@ -870,15 +969,19 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
870
969
  context.selfConfig.target_ip = ipConfig.value!;
871
970
  }
872
971
 
873
- // target_node is the live-resolved reality (ISS-0090) inject it directly,
874
- // never from a cached __infra_target_node row (which drifts).
972
+ // target_node and storage are the live-resolved reality of an existing
973
+ // container (ISS-0090) — inject them directly, never from a cached
974
+ // __infra_* row (which drifts against the service default).
875
975
  if (resolvedTargetNode) {
876
976
  context.selfConfig.target_node = resolvedTargetNode;
877
977
  }
978
+ if (resolvedStorage) {
979
+ context.selfConfig.storage = resolvedStorage;
980
+ }
878
981
 
879
- // lxc_template / storage are provider config (intent, not drift-prone) — read
880
- // them back from the __infra_* rows persisted above.
881
- const infraKeys = ['lxc_template', 'storage', 'vm_template'];
982
+ // lxc_template / vm_template are provider config (intent, not drift-prone) —
983
+ // read them back from the __infra_* rows persisted above.
984
+ const infraKeys = ['lxc_template', 'vm_template'];
882
985
  for (const key of infraKeys) {
883
986
  const infraConfig = db
884
987
  .select()