@celilo/cli 0.5.0-alpha.0 → 0.5.0-alpha.2

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.
@@ -6,6 +6,7 @@ import { and, eq } from 'drizzle-orm';
6
6
  import { generateInventory } from '../ansible/inventory';
7
7
  import { generateAnsibleSecrets } from '../ansible/secrets';
8
8
  import { log } from '../cli/prompts';
9
+ import { getModuleStoragePath } from '../config/paths';
9
10
  import { type DbClient, getDb } from '../db/client';
10
11
  import {
11
12
  capabilities,
@@ -195,12 +196,69 @@ export function injectProxmoxLxcDns(content: string, hasNameserver: boolean): st
195
196
  // schema attributes in telmate ~>2.9 and `terraform validate` rejects them.
196
197
  // `nameserver` stays for the original DNS reason; `rootfs.size` is NOT ignored
197
198
  // so a disk grow still applies in place.
198
- injected.push(`${inner} ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]`);
199
+ // ISS-0089: also ignore `ssh_public_keys` — it's ForceNew, and on a module
200
+ // deployed before the current fleet key, the LXC's birth keys differ from the
201
+ // regenerated config, forcing a spurious REPLACE on redeploy. Rotating the
202
+ // authorized keys must never recreate the container. We do NOT ignore
203
+ // `target_node` — a node change is real and is handled by migration (ISS-0090),
204
+ // never silently dropped.
205
+ injected.push(
206
+ `${inner} ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]`,
207
+ );
199
208
  injected.push(`${inner}}`);
200
209
  return injected.join('\n');
201
210
  });
202
211
  }
203
212
 
213
+ /**
214
+ * Extract the node a proxmox_lxc resource is deployed on from a parsed terraform
215
+ * state object. Pure logic (Rule 10), split from the file read for testability.
216
+ * Prefers the explicit `target_node` attribute; falls back to parsing the
217
+ * resource id (`<node>/lxc/<vmid>`). Returns null when there's no proxmox_lxc
218
+ * resource (e.g. an empty/fresh state).
219
+ */
220
+ export function targetNodeFromTfState(state: {
221
+ resources?: Array<{
222
+ type?: string;
223
+ instances?: Array<{ attributes?: { target_node?: string; id?: string } }>;
224
+ }>;
225
+ }): string | null {
226
+ const lxc = state.resources?.find((r) => r.type === 'proxmox_lxc');
227
+ const attrs = lxc?.instances?.[0]?.attributes;
228
+ if (!attrs) {
229
+ return null;
230
+ }
231
+ if (attrs.target_node) {
232
+ return attrs.target_node;
233
+ }
234
+ // id format: "<node>/lxc/<vmid>"
235
+ return attrs.id?.split('/')[0] || null;
236
+ }
237
+
238
+ /**
239
+ * Read the node a module's container is currently deployed on, from its terraform
240
+ * state file. This is celilo's authoritative record of placement (ISS-0090).
241
+ * Returns null when no state exists yet (a first deploy) or it can't be read —
242
+ * the caller then falls back to the service `default_target_node`.
243
+ */
244
+ async function readDeployedTargetNode(moduleId: string): Promise<string | null> {
245
+ const statePath = join(
246
+ getModuleStoragePath(),
247
+ moduleId,
248
+ 'generated',
249
+ 'terraform',
250
+ 'terraform.tfstate',
251
+ );
252
+ if (!existsSync(statePath)) {
253
+ return null;
254
+ }
255
+ try {
256
+ return targetNodeFromTfState(JSON.parse(await readFile(statePath, 'utf-8')));
257
+ } catch {
258
+ return null;
259
+ }
260
+ }
261
+
204
262
  /**
205
263
  * Discover template files in directory recursively
206
264
  *
@@ -638,9 +696,28 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
638
696
  storage: string;
639
697
  };
640
698
 
699
+ // ISS-0090: target the node the container ACTUALLY lives on, not the
700
+ // service default. `default_target_node` governs only NEW placement; a
701
+ // changed default must NEVER relocate a running system. celilo's terraform
702
+ // state is its authoritative record of where it placed this container (kept
703
+ // in sync by deploy and by `module migrate`), so read the deployed node from
704
+ // there. Fall back to the default only when there's no state yet — a first
705
+ // deploy. Deliberate relocation is an explicit `module migrate`, not a
706
+ // side-effect of the default changing.
707
+ let targetNode = providerConfig.default_target_node;
708
+ const deployedNode = await readDeployedTargetNode(moduleId);
709
+ if (deployedNode) {
710
+ if (deployedNode !== targetNode) {
711
+ log.info(
712
+ `${moduleId} is already deployed on node '${deployedNode}' — targeting it (service default is '${providerConfig.default_target_node}'). Relocating requires a deliberate migration.`,
713
+ );
714
+ }
715
+ targetNode = deployedNode;
716
+ }
717
+
641
718
  // Store provider config values as temporary config (similar to IPAM allocation)
642
719
  const infraProperties = [
643
- { key: 'target_node', value: providerConfig.default_target_node },
720
+ { key: 'target_node', value: targetNode },
644
721
  { key: 'lxc_template', value: providerConfig.lxc_template },
645
722
  { key: 'storage', value: providerConfig.storage },
646
723
  ];
@@ -649,9 +726,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
649
726
  upsertModuleConfig(db, moduleId, `__infra_${prop.key}`, prop.value);
650
727
  }
651
728
 
652
- log.success(
653
- `Infrastructure properties resolved from service: target_node=${providerConfig.default_target_node}`,
654
- );
729
+ log.success(`Infrastructure properties resolved from service: target_node=${targetNode}`);
655
730
  }
656
731
  }
657
732