@celilo/cli 0.5.0-alpha.8 → 0.5.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 (41) hide show
  1. package/package.json +1 -1
  2. package/src/api-clients/proxmox.test.ts +108 -20
  3. package/src/api-clients/proxmox.ts +130 -24
  4. package/src/cli/command-registry.ts +32 -3
  5. package/src/cli/commands/backup-delete.ts +10 -7
  6. package/src/cli/commands/backup-import.ts +11 -8
  7. package/src/cli/commands/backup-restore.ts +11 -8
  8. package/src/cli/commands/events.ts +8 -3
  9. package/src/cli/commands/machine-add.ts +178 -163
  10. package/src/cli/commands/machine-remove.ts +10 -7
  11. package/src/cli/commands/module-config.test.ts +78 -0
  12. package/src/cli/commands/module-config.ts +18 -3
  13. package/src/cli/commands/module-import.ts +9 -5
  14. package/src/cli/commands/module-remove.ts +20 -9
  15. package/src/cli/commands/module-status.ts +15 -0
  16. package/src/cli/commands/module-upgrade.ts +10 -6
  17. package/src/cli/commands/proxmox-node-list.ts +101 -0
  18. package/src/cli/commands/proxmox-template-selection.ts +16 -15
  19. package/src/cli/commands/proxmox-vm-template-build.ts +25 -18
  20. package/src/cli/commands/service-add-digitalocean.ts +120 -109
  21. package/src/cli/commands/service-add-proxmox.ts +275 -260
  22. package/src/cli/commands/service-reconfigure.ts +171 -153
  23. package/src/cli/commands/service-remove.ts +19 -13
  24. package/src/cli/commands/service-verify.ts +9 -10
  25. package/src/cli/commands/storage-add-local.ts +120 -107
  26. package/src/cli/commands/storage-add-s3.ts +145 -131
  27. package/src/cli/commands/storage-remove.ts +11 -8
  28. package/src/cli/commands/system-init.ts +119 -128
  29. package/src/cli/completion.ts +15 -0
  30. package/src/cli/index.ts +25 -0
  31. package/src/cli/service-credential.ts +54 -0
  32. package/src/services/bus-interview.ts +232 -0
  33. package/src/services/module-config.ts +12 -0
  34. package/src/services/module-deploy.ts +6 -1
  35. package/src/services/placement-reconcile.test.ts +86 -0
  36. package/src/services/placement-reconcile.ts +108 -0
  37. package/src/services/programmatic-responder.ts +34 -0
  38. package/src/services/proxmox-state-recovery.ts +26 -7
  39. package/src/services/terminal-responder.ts +113 -0
  40. package/src/templates/generator.test.ts +52 -1
  41. package/src/templates/generator.ts +106 -37
@@ -5,6 +5,7 @@ import { dirname, join, relative } from 'node:path';
5
5
  import { and, eq } from 'drizzle-orm';
6
6
  import { generateInventory } from '../ansible/inventory';
7
7
  import { generateAnsibleSecrets } from '../ansible/secrets';
8
+ import { ProxmoxClient, type ProxmoxCredentials } from '../api-clients/proxmox';
8
9
  import { log } from '../cli/prompts';
9
10
  import { getModuleStoragePath } from '../config/paths';
10
11
  import { type DbClient, getDb } from '../db/client';
@@ -19,12 +20,14 @@ import {
19
20
  import { getSingularSystemSpec } from '../manifest/schema';
20
21
  import type { AnsibleCollection, ModuleManifest } from '../manifest/schema';
21
22
  import { validateZoneRequirements } from '../manifest/validate';
23
+ import { getServiceCredentials } from '../services/container-service';
24
+ import { getModuleSystems } from '../services/deployed-systems';
22
25
  import {
23
26
  describeCapabilityProblem,
24
27
  findBrokenCapabilityDerivations,
25
28
  } from '../services/fleet-checks';
26
29
  import { selectInfrastructure } from '../services/infrastructure-selector';
27
- import { upsertModuleConfig } from '../services/module-config';
30
+ import { deleteModuleConfig, upsertModuleConfig } from '../services/module-config';
28
31
  import type { InfrastructureSelection } from '../types/infrastructure';
29
32
  import { convertSecretsToJinja } from '../variables/ansible-resolver';
30
33
  import { buildResolutionContext } from '../variables/context';
@@ -213,10 +216,11 @@ export function injectProxmoxDns(content: string, hasNameserver: boolean): strin
213
216
  }
214
217
 
215
218
  /**
216
- * Extract the node a proxmox_lxc resource is deployed on from a parsed terraform
217
- * state object. Pure logic (Rule 10), split from the file read for testability.
218
- * Prefers the explicit `target_node` attribute; falls back to parsing the
219
- * resource id (`<node>/lxc/<vmid>`). Returns null when there's no proxmox_lxc
219
+ * Extract the node a Proxmox guest is deployed on from a parsed terraform state
220
+ * object — an LXC (proxmox_lxc) or a VM (proxmox_vm_qemu). Pure logic (Rule 10),
221
+ * split from the file read for testability. Prefers the explicit `target_node`
222
+ * attribute; falls back to parsing the resource id (`<node>/lxc/<vmid>` for an
223
+ * LXC, `<node>/qemu/<vmid>` for a VM). Returns null when there's no such
220
224
  * resource (e.g. an empty/fresh state).
221
225
  */
222
226
  export function targetNodeFromTfState(state: {
@@ -225,15 +229,17 @@ export function targetNodeFromTfState(state: {
225
229
  instances?: Array<{ attributes?: { target_node?: string; id?: string } }>;
226
230
  }>;
227
231
  }): string | null {
228
- const lxc = state.resources?.find((r) => r.type === 'proxmox_lxc');
229
- const attrs = lxc?.instances?.[0]?.attributes;
232
+ const guest = state.resources?.find(
233
+ (r) => r.type === 'proxmox_lxc' || r.type === 'proxmox_vm_qemu',
234
+ );
235
+ const attrs = guest?.instances?.[0]?.attributes;
230
236
  if (!attrs) {
231
237
  return null;
232
238
  }
233
239
  if (attrs.target_node) {
234
240
  return attrs.target_node;
235
241
  }
236
- // id format: "<node>/lxc/<vmid>"
242
+ // id format: "<node>/lxc/<vmid>" (LXC) or "<node>/qemu/<vmid>" (VM)
237
243
  return attrs.id?.split('/')[0] || null;
238
244
  }
239
245
 
@@ -261,6 +267,49 @@ async function readDeployedTargetNode(moduleId: string): Promise<string | null>
261
267
  }
262
268
  }
263
269
 
270
+ /**
271
+ * The node a module's container ACTUALLY lives on, from Proxmox (ISS-0090).
272
+ * Proxmox is the ultimate source of truth for current location — it sees a
273
+ * hand-migration that celilo's terraform state wouldn't. Returns null when the
274
+ * module has no deployed vmid yet, the service is unreachable, or the vmid isn't
275
+ * in the cluster — the caller then falls back to terraform state / the default.
276
+ * Never throws: a Proxmox outage must not block a deploy.
277
+ */
278
+ async function readProxmoxNodeForModule(
279
+ moduleId: string,
280
+ serviceId: string,
281
+ db: DbClient,
282
+ ): Promise<string | null> {
283
+ const vmid = getModuleSystems(moduleId, db).find(
284
+ (s) => s.infrastructure.type === 'container_service' && s.infrastructure.vmid != null,
285
+ )?.infrastructure.vmid;
286
+ if (vmid == null) return null;
287
+ try {
288
+ const creds = (await getServiceCredentials(serviceId)) as ProxmoxCredentials;
289
+ const result = await new ProxmoxClient(creds).nodeForVmid(vmid);
290
+ return result.success ? result.data : null;
291
+ } catch {
292
+ return null;
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Decide which node to target for a deploy (ISS-0090). Pure (Rule 10):
298
+ * Proxmox reality > recorded terraform state > service default.
299
+ * `default_target_node` governs only a FIRST placement; a changed default must
300
+ * never relocate a running container. A hand-migration (seen by Proxmox but not
301
+ * tf-state) is adopted. Deliberate moves are an explicit migrate (ISS-0062).
302
+ */
303
+ export function decideTargetNode(opts: {
304
+ proxmoxNode: string | null;
305
+ stateNode: string | null;
306
+ defaultNode: string;
307
+ }): { node: string; source: 'proxmox' | 'state' | 'default' } {
308
+ if (opts.proxmoxNode) return { node: opts.proxmoxNode, source: 'proxmox' };
309
+ if (opts.stateNode) return { node: opts.stateNode, source: 'state' };
310
+ return { node: opts.defaultNode, source: 'default' };
311
+ }
312
+
264
313
  /**
265
314
  * Discover template files in directory recursively
266
315
  *
@@ -684,6 +733,9 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
684
733
  // Infrastructure Properties Resolution (Proxmox provider config)
685
734
  // For Proxmox services, extract provider config and store as temporary values
686
735
  // This happens during generation so templates can access target_node, lxc_template, etc.
736
+ // Resolved live each generate (ISS-0090) and injected into the context below,
737
+ // never cached in the DB. undefined for non-Proxmox / machine deploys.
738
+ let resolvedTargetNode: string | undefined;
687
739
  if (isContainerService && isProxmoxService && infrastructureSelection?.serviceId) {
688
740
  const service = await db
689
741
  .select()
@@ -696,39 +748,49 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
696
748
  default_target_node: string;
697
749
  lxc_template: string;
698
750
  storage: string;
751
+ // Optional: only set once a cloud-init VM template has been built/selected
752
+ // for the service (see service reconfigure). Required by `type: vm` modules;
753
+ // absent for services that only deploy LXCs.
754
+ vm_template?: string;
699
755
  };
700
756
 
701
- // ISS-0090: target the node the container ACTUALLY lives on, not the
702
- // service default. `default_target_node` governs only NEW placement; a
703
- // changed default must NEVER relocate a running system. celilo's terraform
704
- // state is its authoritative record of where it placed this container (kept
705
- // in sync by deploy and by `module migrate`), so read the deployed node from
706
- // there. Fall back to the default only when there's no state yet — a first
707
- // deploy. Deliberate relocation is an explicit `module migrate`, not a
708
- // side-effect of the default changing.
709
- let targetNode = providerConfig.default_target_node;
710
- const deployedNode = await readDeployedTargetNode(moduleId);
711
- if (deployedNode) {
712
- if (deployedNode !== targetNode) {
713
- log.info(
714
- `${moduleId} is already deployed on node '${deployedNode}' — targeting it (service default is '${providerConfig.default_target_node}'). Relocating requires a deliberate migration.`,
715
- );
716
- }
717
- targetNode = deployedNode;
757
+ // ISS-0090: deploy follows REALITY. Resolve the node from Proxmox (it sees
758
+ // a hand-migration that tf-state wouldn't), else the recorded terraform
759
+ // state, else the service default (FIRST placement only). A changed default
760
+ // must never relocate a running container; deliberate moves are an explicit
761
+ // migrate (ISS-0062).
762
+ const decision = decideTargetNode({
763
+ proxmoxNode: await readProxmoxNodeForModule(
764
+ moduleId,
765
+ infrastructureSelection.serviceId,
766
+ db,
767
+ ),
768
+ stateNode: await readDeployedTargetNode(moduleId),
769
+ defaultNode: providerConfig.default_target_node,
770
+ });
771
+ resolvedTargetNode = decision.node;
772
+ if (decision.source !== 'default' && decision.node !== providerConfig.default_target_node) {
773
+ const from = decision.source === 'proxmox' ? 'Proxmox' : 'terraform state';
774
+ log.info(
775
+ `${moduleId} → node '${decision.node}' (from ${from}; service default is '${providerConfig.default_target_node}'). Relocating requires a deliberate migration.`,
776
+ );
718
777
  }
719
778
 
720
- // Store provider config values as temporary config (similar to IPAM allocation)
721
- const infraProperties = [
722
- { key: 'target_node', value: targetNode },
723
- { key: 'lxc_template', value: providerConfig.lxc_template },
724
- { key: 'storage', value: providerConfig.storage },
725
- ];
726
-
727
- for (const prop of infraProperties) {
728
- upsertModuleConfig(db, moduleId, `__infra_${prop.key}`, prop.value);
779
+ // Persist only the non-drift provider values. target_node is reality it's
780
+ // injected into the resolution context below, never cached (ISS-0090); drop
781
+ // any stale __infra_target_node a prior generate left behind.
782
+ upsertModuleConfig(db, moduleId, '__infra_lxc_template', providerConfig.lxc_template);
783
+ upsertModuleConfig(db, moduleId, '__infra_storage', providerConfig.storage);
784
+ // vm_template is only present once a VM template exists for the service.
785
+ // Persist it when set so `type: vm` modules can resolve $self:vm_template;
786
+ // a `type: vm` deploy against a service without one then fails loudly at
787
+ // variable resolution (the intended "no VM template configured" error).
788
+ if (providerConfig.vm_template) {
789
+ upsertModuleConfig(db, moduleId, '__infra_vm_template', providerConfig.vm_template);
729
790
  }
791
+ deleteModuleConfig(db, moduleId, '__infra_target_node');
730
792
 
731
- log.success(`Infrastructure properties resolved from service: target_node=${targetNode}`);
793
+ log.success(`Infrastructure resolved: target_node=${decision.node} (${decision.source})`);
732
794
  }
733
795
  }
734
796
 
@@ -760,8 +822,15 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
760
822
  context.selfConfig.target_ip = ipConfig.value!;
761
823
  }
762
824
 
763
- // Add infrastructure properties to context (target_node, lxc_template, storage)
764
- const infraKeys = ['target_node', 'lxc_template', 'storage'];
825
+ // target_node is the live-resolved reality (ISS-0090) — inject it directly,
826
+ // never from a cached __infra_target_node row (which drifts).
827
+ if (resolvedTargetNode) {
828
+ context.selfConfig.target_node = resolvedTargetNode;
829
+ }
830
+
831
+ // lxc_template / storage are provider config (intent, not drift-prone) — read
832
+ // them back from the __infra_* rows persisted above.
833
+ const infraKeys = ['lxc_template', 'storage', 'vm_template'];
765
834
  for (const key of infraKeys) {
766
835
  const infraConfig = db
767
836
  .select()