@celilo/cli 0.13.1 → 0.13.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -74,6 +74,22 @@
74
74
  "maximum": 4094,
75
75
  "description": "VLAN tag for internal zone (not defaulted; internal is untagged)"
76
76
  },
77
+ "network.secure-mgmt.subnet": {
78
+ "type": "string",
79
+ "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
80
+ "description": "Control-plane subnet CIDR — the network celilo-mgr itself occupies when it does not sit on the internal LAN (not defaulted — discovered at celilo-mgmt install, or recorded by firewall onboarding). Derives the firewall's trusted sources and the internal resolver's split-horizon view for celilo's own traffic."
81
+ },
82
+ "network.secure-mgmt.gateway": {
83
+ "type": "string",
84
+ "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$",
85
+ "description": "Control-plane gateway IP address (not defaulted — discovered from the default route)"
86
+ },
87
+ "network.secure-mgmt.vlan": {
88
+ "type": "integer",
89
+ "minimum": 1,
90
+ "maximum": 4094,
91
+ "description": "VLAN tag for the control-plane zone (not defaulted)"
92
+ },
77
93
  "dns.primary": {
78
94
  "type": "string",
79
95
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$",
@@ -6,7 +6,7 @@ import type { DbClient } from '../db/client';
6
6
  import { machines, moduleConfigs, systemConfig } from '../db/schema';
7
7
  import { getModuleSystems } from '../services/deployed-systems';
8
8
  import { parseStoredConfigValue } from '../services/module-config';
9
- import { getTempKeyPath } from '../services/ssh-key-manager';
9
+ import { LOCAL_MACHINE_IP, getTempKeyPath } from '../services/ssh-key-manager';
10
10
 
11
11
  /**
12
12
  * Inventory generation result
@@ -395,7 +395,7 @@ export async function generateInventory(
395
395
 
396
396
  // Build host definition from machine. The local box (127.0.0.1)
397
397
  // uses Ansible's local connection — no SSH key/host.
398
- const isLocal = machine.ipAddress === '127.0.0.1';
398
+ const isLocal = machine.ipAddress === LOCAL_MACHINE_IP;
399
399
  host = {
400
400
  hostname: moduleHostname || machine.hostname,
401
401
  ansibleHost: machine.ipAddress,
@@ -355,6 +355,8 @@ _celilo_system_config_keys() {
355
355
  'network.secure.gateway:Secure gateway IP'
356
356
  'network.internal.subnet:Internal subnet'
357
357
  'network.internal.gateway:Internal gateway IP'
358
+ 'network.secure-mgmt.subnet:Control-plane subnet (celilo-mgr own network)'
359
+ 'network.secure-mgmt.gateway:Control-plane gateway IP'
358
360
  'dns.primary:Primary DNS server'
359
361
  'dns.fallback:Fallback DNS servers'
360
362
  'routing.internal_gateway:Internal gateway IP'
@@ -51,7 +51,7 @@ import { executeAnsible } from './deploy-ansible';
51
51
  import { getContainerSystemsInZones } from './deployed-systems';
52
52
  import { getSystemsByZone } from './machine-pool';
53
53
  import { executeProxmoxReconcile, planProxmoxReconcile } from './proxmox-reconcile';
54
- import { writeTemporarySshKey } from './ssh-key-manager';
54
+ import { LOCAL_MACHINE_IP, writeTemporarySshKey } from './ssh-key-manager';
55
55
 
56
56
  type DbClient = ReturnType<typeof getDb>;
57
57
 
@@ -231,7 +231,7 @@ export async function materializeAspectAnsible(args: {
231
231
  // (ansible/inventory.ts). Without this the fan-out tried `ssh root@127.0.0.1`
232
232
  // with an empty key file and the host failed UNREACHABLE with an opaque
233
233
  // "error in libcrypto", so celilo could not configure its own resolv.conf.
234
- const isLocal = t.ipAddress === '127.0.0.1';
234
+ const isLocal = t.ipAddress === LOCAL_MACHINE_IP;
235
235
  const keyPath = t.machineId && !isLocal ? await writeTemporarySshKey(t.machineId) : undefined;
236
236
  inventoryHosts.push({
237
237
  hostname: t.hostname,
@@ -40,7 +40,7 @@ import { resolveInfrastructureVariables } from './infrastructure-variable-resolv
40
40
  import { findMachineForModule } from './machine-pool';
41
41
  import { checkProxmoxReachable, formatProxmoxUnreachableError } from './proxmox-preflight';
42
42
  import { republishStaticWebConsumers } from './public-web-republish';
43
- import { deleteTemporarySshKey, writeTemporarySshKey } from './ssh-key-manager';
43
+ import { LOCAL_MACHINE_IP, deleteTemporarySshKey, writeTemporarySshKey } from './ssh-key-manager';
44
44
  import { buildTerraformEnvForService } from './terraform-env';
45
45
 
46
46
  export interface DeployResult {
@@ -1129,8 +1129,20 @@ async function deployModuleImpl(
1129
1129
 
1130
1130
  let machineId: string | undefined;
1131
1131
  if (plan.infrastructure?.type === 'machine' && plan.infrastructure.machineId) {
1132
- machineId = plan.infrastructure.machineId;
1133
- await writeTemporarySshKey(machineId);
1132
+ // The management box deploys to ITSELF (registered in the machine pool as
1133
+ // 127.0.0.1) over Ansible's local connection — it has no stored SSH key,
1134
+ // and pinning one here throws before Ansible ever runs. `inventory.ts` and
1135
+ // `aspect-runner.ts` both already skip the key for the local box; this call
1136
+ // site did not, so celilo-mgmt could not complete a self-deploy.
1137
+ const machine = db
1138
+ .select()
1139
+ .from(machines)
1140
+ .where(eq(machines.id, plan.infrastructure.machineId))
1141
+ .get();
1142
+ if (machine?.ipAddress !== LOCAL_MACHINE_IP) {
1143
+ machineId = plan.infrastructure.machineId;
1144
+ await writeTemporarySshKey(machineId);
1145
+ }
1134
1146
  }
1135
1147
 
1136
1148
  try {
@@ -10,6 +10,7 @@ import { closeDb } from '../db/client';
10
10
  import { runMigrations } from '../db/migrate';
11
11
  import { addMachine } from './machine-pool';
12
12
  import {
13
+ LOCAL_MACHINE_IP,
13
14
  ManagedSshKey,
14
15
  cleanupTemporarySshKeys,
15
16
  deleteTemporarySshKey,
@@ -326,3 +327,16 @@ describe('ssh-key-manager', () => {
326
327
  });
327
328
  });
328
329
  });
330
+
331
+ // The management box is reached over Ansible's local connection, never by
332
+ // `ssh root@127.0.0.1`, so callers must skip key materialization for it. Three
333
+ // call sites decide this — inventory.ts, aspect-runner.ts and module-deploy.ts —
334
+ // and module-deploy.ts did NOT, so `celilo module deploy celilo-mgmt` threw
335
+ // "has no SSH key stored" before Ansible ever ran and the control plane could
336
+ // not self-deploy. The literal now lives in one place so a fourth caller finds
337
+ // it instead of retyping it.
338
+ describe('LOCAL_MACHINE_IP', () => {
339
+ it('is the loopback address the management box registers itself under', () => {
340
+ expect(LOCAL_MACHINE_IP).toBe('127.0.0.1');
341
+ });
342
+ });
@@ -17,6 +17,18 @@ function getTempKeysDir(): string {
17
17
  return join(tmpdir(), 'celilo-ansible-keys');
18
18
  }
19
19
 
20
+ /**
21
+ * The address the management box registers itself under in its own machine pool.
22
+ *
23
+ * celilo-mgr deploys to ITSELF, so it appears in the pool as a machine — but it
24
+ * has no stored SSH key and must be reached over Ansible's local connection
25
+ * rather than `ssh root@127.0.0.1`. Three call sites need to know this
26
+ * (`inventory.ts`, `aspect-runner.ts`, `module-deploy.ts`); one of them silently
27
+ * didn't, and celilo-mgmt could not complete a self-deploy as a result. Exported
28
+ * so the next caller finds it instead of retyping the literal.
29
+ */
30
+ export const LOCAL_MACHINE_IP = '127.0.0.1';
31
+
20
32
  /**
21
33
  * Ensure the temp keys directory exists
22
34
  */
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
2
4
  import type { SystemConfigProperty, SystemConfigSchema } from './system-config-schema-types';
3
5
  import { validateKey, validateValue } from './system-config-validator';
4
6
 
@@ -158,3 +160,26 @@ describe('validateKey', () => {
158
160
  expect(result.error).toContain('network.bridge');
159
161
  });
160
162
  });
163
+
164
+ // The shipped schema is what `celilo system config set` validates against, and it
165
+ // is a hand-maintained enumeration of zones. `secure-mgmt` was added to
166
+ // NETWORK_ZONES, IPAM, zone detection and the manifest schema but NOT here, so the
167
+ // zone existed everywhere except the one place its subnet could be RECORDED — and
168
+ // recording it is what derives firewall trust and the resolver's split-horizon
169
+ // view. It failed only on the live box, at `system config set`.
170
+ describe('shipped system_config.json covers every placement zone', () => {
171
+ const shipped = JSON.parse(
172
+ readFileSync(join(import.meta.dir, '../../schemas/system_config.json'), 'utf-8'),
173
+ ) as SystemConfigSchema;
174
+
175
+ // `external` is deliberately absent: cloud/VPS systems carry their own
176
+ // addressing and celilo allocates nothing for them.
177
+ const ADDRESSABLE_ZONES = ['internal', 'dmz', 'app', 'secure', 'secure-mgmt'] as const;
178
+
179
+ for (const zone of ADDRESSABLE_ZONES) {
180
+ it(`accepts network.${zone}.subnet and .gateway`, () => {
181
+ expect(validateKey(`network.${zone}.subnet`, shipped).valid).toBe(true);
182
+ expect(validateKey(`network.${zone}.gateway`, shipped).valid).toBe(true);
183
+ });
184
+ }
185
+ });