@celilo/cli 0.8.1 → 0.9.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 (34) hide show
  1. package/AGENTS.md +86 -0
  2. package/CELILO_CORE_MODULES.md +61 -0
  3. package/CELILO_SUBSYSTEMS.md +83 -0
  4. package/drizzle/0012_module_systems_sizing.sql +3 -0
  5. package/drizzle/0013_dns_view_overrides.sql +1 -0
  6. package/drizzle/meta/_journal.json +14 -0
  7. package/package.json +6 -3
  8. package/src/capabilities/well-known.test.ts +12 -7
  9. package/src/capabilities/well-known.ts +11 -3
  10. package/src/cli/command-registry.ts +65 -1
  11. package/src/cli/commands/module-upgrade.test.ts +29 -0
  12. package/src/cli/commands/module-upgrade.ts +57 -24
  13. package/src/cli/commands/proxmox-instance-list.test.ts +77 -0
  14. package/src/cli/commands/proxmox-instance-list.ts +140 -0
  15. package/src/cli/commands/proxmox-instance-resize.ts +235 -0
  16. package/src/cli/commands/proxmox-node-list.ts +1 -34
  17. package/src/cli/commands/proxmox-resize-guards.test.ts +55 -0
  18. package/src/cli/commands/proxmox-resize-guards.ts +102 -0
  19. package/src/cli/commands/proxmox-service.ts +38 -0
  20. package/src/cli/completion.ts +11 -3
  21. package/src/cli/index.ts +15 -0
  22. package/src/db/schema.ts +21 -1
  23. package/src/hooks/capability-loader.ts +22 -0
  24. package/src/manifest/template-validator.test.ts +31 -1
  25. package/src/manifest/template-validator.ts +9 -0
  26. package/src/services/deployed-systems.test.ts +73 -1
  27. package/src/services/deployed-systems.ts +72 -0
  28. package/src/services/dns-internal-records.test.ts +76 -3
  29. package/src/services/dns-internal-records.ts +52 -3
  30. package/src/services/dns-provider-backfill.ts +15 -3
  31. package/src/services/fleet-checks.test.ts +18 -16
  32. package/src/services/machine-detector.ts +34 -12
  33. package/src/templates/generator.ts +49 -1
  34. package/src/variables/context.ts +36 -7
@@ -18,7 +18,11 @@ import type { DnsInternalCapability, HookLogger } from '@celilo/capabilities';
18
18
  import { and, eq, inArray, or } from 'drizzle-orm';
19
19
  import type { DbClient } from '../db/client';
20
20
  import { capabilities as capabilitiesTable, modules, webRoutes } from '../db/schema';
21
- import { loadCapabilityFunctions, resolveFirewallNatIp } from '../hooks/capability-loader';
21
+ import {
22
+ loadCapabilityFunctions,
23
+ resolveCaddyZoneIp,
24
+ resolveFirewallNatIp,
25
+ } from '../hooks/capability-loader';
22
26
  import { runNamedHook } from '../hooks/run-named-hook';
23
27
  import type { HookName } from '../hooks/types';
24
28
  import { getModuleSystems } from './deployed-systems';
@@ -137,13 +141,21 @@ export async function backfillWebRouteDns(
137
141
  return;
138
142
  }
139
143
 
144
+ // caddy's zone-routable IP — the in-zone split-horizon answer (ISS-0156). When
145
+ // it differs from the natIp, each fronted hostname carries it as
146
+ // `zoneRoutableValue`; the provider's reconcileViews (driven from the ledger by
147
+ // the registration wrapper) materializes the per-zone view overrides.
148
+ const caddyZoneIp = await resolveCaddyZoneIp(db);
149
+ const zoneRoutableValue = caddyZoneIp && caddyZoneIp !== natIp ? caddyZoneIp : undefined;
150
+ const viewNote = zoneRoutableValue ? ` (in-zone view → ${zoneRoutableValue})` : '';
151
+
140
152
  logger.info(
141
- `Backfilling ${hostnames.length} web-route hostname(s) into '${moduleId}' at ${natIp}`,
153
+ `Backfilling ${hostnames.length} web-route hostname(s) into '${moduleId}' at ${natIp}${viewNote}`,
142
154
  );
143
155
  const failures: string[] = [];
144
156
  for (const host of hostnames) {
145
157
  try {
146
- await dnsInternal.registerRecord({ host, type: 'A', value: natIp });
158
+ await dnsInternal.registerRecord({ host, type: 'A', value: natIp, zoneRoutableValue });
147
159
  } catch (err) {
148
160
  failures.push(`${host}: ${err instanceof Error ? err.message : String(err)}`);
149
161
  }
@@ -242,7 +242,7 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
242
242
  variables: {
243
243
  owns: [
244
244
  {
245
- name: 'idp_dmz_ip',
245
+ name: 'idp_auth_url',
246
246
  type: 'string',
247
247
  required: true,
248
248
  source: 'capability',
@@ -254,21 +254,21 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
254
254
  });
255
255
 
256
256
  it('fails when the consumed capability has no deployed provider', () => {
257
- insertModule('forgejo', consumer('$capability:idp.dmz_ip'));
257
+ insertModule('forgejo', consumer('$capability:idp.auth_url'));
258
258
  const f = checkCapabilityProviders(db);
259
259
  expect(f.status).toBe('fail');
260
260
  expect(f.detail.join(' ')).toContain("no deployed module provides 'idp'");
261
261
  });
262
262
 
263
263
  it('fails when the provider lacks the referenced field', () => {
264
- insertModule('forgejo', consumer('$capability:idp.dmz_ip'));
264
+ insertModule('forgejo', consumer('$capability:idp.auth_url'));
265
265
  insertModule('authentik', baseManifest({ id: 'authentik', name: 'Authentik' }));
266
266
  db.insert(capabilitiesTable)
267
267
  .values({
268
268
  moduleId: 'authentik',
269
269
  capabilityName: 'idp',
270
270
  version: '1.0.0',
271
- data: { auth_url: 'x' },
271
+ data: { admin_email: 'x' },
272
272
  })
273
273
  .run();
274
274
  const f = checkCapabilityProviders(db);
@@ -277,14 +277,14 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
277
277
  });
278
278
 
279
279
  it('passes when the provider carries a concrete value', () => {
280
- insertModule('forgejo', consumer('$capability:idp.dmz_ip'));
280
+ insertModule('forgejo', consumer('$capability:idp.auth_url'));
281
281
  insertModule('authentik', baseManifest({ id: 'authentik', name: 'Authentik' }));
282
282
  db.insert(capabilitiesTable)
283
283
  .values({
284
284
  moduleId: 'authentik',
285
285
  capabilityName: 'idp',
286
286
  version: '1.0.0',
287
- data: { dmz_ip: '10.0.10.10' },
287
+ data: { auth_url: 'https://auth.celilo.computer' },
288
288
  })
289
289
  .run();
290
290
  const f = checkCapabilityProviders(db);
@@ -292,16 +292,16 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
292
292
  });
293
293
 
294
294
  it('passes but flags a derived ref for the chain trace (ISS-0114)', () => {
295
- // The workstream-B case: idp.dmz_ip is present but is itself a ref
296
- // ($self:caddy_dmz_ip) — we can't verify it resolves without the walker.
297
- insertModule('forgejo', consumer('$capability:idp.dmz_ip'));
295
+ // idp.auth_url is present but is itself a ref ($self:auth_url) — we can't
296
+ // verify it resolves without the walker.
297
+ insertModule('forgejo', consumer('$capability:idp.auth_url'));
298
298
  insertModule('authentik', baseManifest({ id: 'authentik', name: 'Authentik' }));
299
299
  db.insert(capabilitiesTable)
300
300
  .values({
301
301
  moduleId: 'authentik',
302
302
  capabilityName: 'idp',
303
303
  version: '1.0.0',
304
- data: { dmz_ip: '$self:caddy_dmz_ip' },
304
+ data: { auth_url: '$self:auth_url' },
305
305
  })
306
306
  .run();
307
307
  const f = checkCapabilityProviders(db);
@@ -442,11 +442,11 @@ describe('findBrokenCapabilityDerivations (shared predicate)', () => {
442
442
  variables: {
443
443
  owns: [
444
444
  {
445
- name: 'idp_dmz_ip',
445
+ name: 'idp_auth_url',
446
446
  type: 'string',
447
447
  required: true,
448
448
  source: 'capability',
449
- derive_from: '$capability:idp.dmz_ip',
449
+ derive_from: '$capability:idp.auth_url',
450
450
  },
451
451
  ],
452
452
  imports: [],
@@ -461,21 +461,23 @@ describe('findBrokenCapabilityDerivations (shared predicate)', () => {
461
461
  });
462
462
 
463
463
  it('flags empty-value when the field is present but empty', () => {
464
- const problems = findBrokenCapabilityDerivations('forgejo', consumer, { idp: { dmz_ip: '' } });
464
+ const problems = findBrokenCapabilityDerivations('forgejo', consumer, {
465
+ idp: { auth_url: '' },
466
+ });
465
467
  expect(problems[0].reason).toBe('empty-value');
466
468
  });
467
469
 
468
470
  it('flags unresolved-ref when the resolved value is still a template', () => {
469
471
  const problems = findBrokenCapabilityDerivations('forgejo', consumer, {
470
- idp: { dmz_ip: '$self:caddy_dmz_ip' },
472
+ idp: { auth_url: '$self:auth_url' },
471
473
  });
472
474
  expect(problems[0].reason).toBe('unresolved-ref');
473
- expect(problems[0].value).toBe('$self:caddy_dmz_ip');
475
+ expect(problems[0].value).toBe('$self:auth_url');
474
476
  });
475
477
 
476
478
  it('returns nothing when the field resolves to a concrete value', () => {
477
479
  const problems = findBrokenCapabilityDerivations('forgejo', consumer, {
478
- idp: { dmz_ip: '10.0.10.10' },
480
+ idp: { auth_url: 'https://auth.celilo.computer' },
479
481
  });
480
482
  expect(problems).toHaveLength(0);
481
483
  });
@@ -27,23 +27,45 @@ export class DetectionError extends Error {
27
27
  export type CommandRunner = (command: string) => string;
28
28
 
29
29
  /**
30
- * Execute SSH command and return output
30
+ * Per-command SSH timeout. Detection opens a fresh SSH connection per
31
+ * attribute (hostname, cpu, memory, ...); under load (e.g. the e2e builder
32
+ * running many containers) a single handshake + command can exceed a tight
33
+ * budget, surfacing as `spawnSync ... ETIMEDOUT` and failing an
34
+ * otherwise-healthy `machine add`. 30s gives headroom without masking a
35
+ * genuinely-unreachable host (which fails fast on connection refusal).
36
+ */
37
+ const SSH_TIMEOUT_MS = 30_000;
38
+ /** Retry attempts for a detection SSH command. Detection is read-only
39
+ * (hostname / nproc / free / ...), so retrying a transient timeout is safe. */
40
+ const SSH_ATTEMPTS = 3;
41
+
42
+ /** Synchronous sleep — detection runs synchronously (execSync), so there's no
43
+ * async context to await in. */
44
+ function sleepSync(ms: number): void {
45
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
46
+ }
47
+
48
+ /**
49
+ * Execute SSH command and return output. Retries transient failures
50
+ * (connection timeouts under load) with linear backoff — safe because every
51
+ * detection command is read-only.
31
52
  */
32
53
  function sshExec(ip: string, user: string, keyPath: string, command: string): string {
33
- try {
34
- const output = execSync(
35
- `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -i "${keyPath}" ${user}@${ip} "${command}"`,
36
- {
54
+ const sshCmd = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=10 -i "${keyPath}" ${user}@${ip} "${command}"`;
55
+ let lastMessage = 'Unknown error';
56
+ for (let attempt = 1; attempt <= SSH_ATTEMPTS; attempt++) {
57
+ try {
58
+ return execSync(sshCmd, {
37
59
  encoding: 'utf8',
38
60
  stdio: ['pipe', 'pipe', 'pipe'],
39
- timeout: 10000, // 10 second timeout
40
- },
41
- );
42
- return output.trim();
43
- } catch (error) {
44
- const message = error instanceof Error ? error.message : 'Unknown error';
45
- throw new DetectionError(`SSH command failed: ${message}`);
61
+ timeout: SSH_TIMEOUT_MS,
62
+ }).trim();
63
+ } catch (error) {
64
+ lastMessage = error instanceof Error ? error.message : 'Unknown error';
65
+ if (attempt < SSH_ATTEMPTS) sleepSync(1000 * attempt);
66
+ }
46
67
  }
68
+ throw new DetectionError(`SSH command failed after ${SSH_ATTEMPTS} attempts: ${lastMessage}`);
47
69
  }
48
70
 
49
71
  /** CommandRunner that SSHes to a remote machine. */
@@ -27,7 +27,11 @@ import {
27
27
  findBrokenCapabilityDerivations,
28
28
  } from '../services/fleet-checks';
29
29
  import { selectInfrastructure } from '../services/infrastructure-selector';
30
- import { deleteModuleConfig, upsertModuleConfig } from '../services/module-config';
30
+ import {
31
+ deleteModuleConfig,
32
+ getModuleConfigValue,
33
+ upsertModuleConfig,
34
+ } from '../services/module-config';
31
35
  import type { InfrastructureSelection } from '../types/infrastructure';
32
36
  import { convertSecretsToJinja } from '../variables/ansible-resolver';
33
37
  import { buildResolutionContext } from '../variables/context';
@@ -730,6 +734,50 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
730
734
  }
731
735
  }
732
736
 
737
+ // Dedicated DNS-ingress IP (ISS-0156). A dns_internal provider now deploys into
738
+ // a PROTECTED zone (dmz) so it can see protected-zone query sources for
739
+ // split-horizon views (v2/INTERNAL_DNS_ZONE_VIEWS.md). `internal` devices have
740
+ // no route into the 10-net, so they reach the resolver through a firewall DNAT
741
+ // on a dedicated `internal`-subnet address. A module opts in by declaring a
742
+ // `dns_ingress_ip` infrastructure variable; we allocate a free IP from the
743
+ // `internal` subnet via IPAM and RESERVE it (so it's never re-handed-out),
744
+ // idempotently (reuse the stored value on re-generate). The resolver's
745
+ // on_install passes it to firewall.exposeService({ ingressIp }).
746
+ const wantsDnsIngress = manifest.variables?.owns?.some(
747
+ (v) => v.name === 'dns_ingress_ip' && v.source === 'infrastructure',
748
+ );
749
+ if (wantsDnsIngress) {
750
+ const existing = getModuleConfigValue(moduleId, 'dns_ingress_ip', db)?.value;
751
+ if (typeof existing === 'string' && existing.length > 0) {
752
+ log.success(`Using existing DNS-ingress IP ${existing} for ${moduleId}`);
753
+ } else {
754
+ const subnetRow = db.$client
755
+ .prepare('SELECT value FROM system_config WHERE key = ?')
756
+ .get('network.internal.subnet') as { value: string } | undefined;
757
+ if (!subnetRow?.value) {
758
+ return {
759
+ success: false,
760
+ error:
761
+ 'network.internal.subnet is not configured — required to allocate the ' +
762
+ 'dns_internal DNS-ingress IP (ISS-0156). Ensure the internal network is set up first.',
763
+ };
764
+ }
765
+ const { allocateIPFromSubnet, reserveIP } = await import('../ipam/allocator');
766
+ const { stripCIDR } = await import('../ipam/subnet-parser');
767
+ try {
768
+ const ip = stripCIDR(await allocateIPFromSubnet(subnetRow.value, 'internal', db));
769
+ await reserveIP(ip, 'internal', `dns-ingress:${moduleId}`, null, db);
770
+ upsertModuleConfig(db, moduleId, 'dns_ingress_ip', ip);
771
+ log.success(`Allocated DNS-ingress IP ${ip} (internal subnet) for ${moduleId}`);
772
+ } catch (error) {
773
+ return {
774
+ success: false,
775
+ error: `DNS-ingress IP allocation failed: ${error instanceof Error ? error.message : String(error)}`,
776
+ };
777
+ }
778
+ }
779
+ }
780
+
733
781
  // Infrastructure Properties Resolution (Proxmox provider config)
734
782
  // For Proxmox services, extract provider config and store as temporary values
735
783
  // This happens during generation so templates can access target_node, lxc_template, etc.
@@ -8,6 +8,7 @@ import {
8
8
  machines,
9
9
  moduleConfigs,
10
10
  moduleInfrastructure,
11
+ moduleSystems,
11
12
  modules,
12
13
  secrets,
13
14
  systemConfig,
@@ -264,20 +265,48 @@ export async function buildResolutionContext(
264
265
  const systemResources = getSingularSystemSpec(manifest);
265
266
 
266
267
  if (systemResources) {
267
- // Map manifest fields to module variable names and apply defaults
268
+ // The DEPLOYED size is the SYSTEM's canonical state (ISS-0150), seeded from
269
+ // requires.system at first provision and thereafter owned by
270
+ // `celilo proxmox … resize`. So sizing flows: module_systems → these config
271
+ // vars → `$self:{cores,memory,disk}` in the instance Terraform.
272
+ //
273
+ // Precedence: the recorded system size WINS and overwrites the cached
274
+ // config (a resize must propagate on the next generate); only when this
275
+ // module has no recorded system size yet (the very first provision, before
276
+ // recordDeployedSystemForModule runs below) do we fall back to
277
+ // requires.system — and seed-when-unset, matching the prior behavior so the
278
+ // first-deploy / golden output is unchanged. `requires.system` stays the
279
+ // minimum floor, never the canonical size. (CLAUDE.md / ISS-0150.)
280
+ const sizedRow = db
281
+ .select({
282
+ cpu: moduleSystems.cpu,
283
+ memory: moduleSystems.memory,
284
+ disk: moduleSystems.disk,
285
+ })
286
+ .from(moduleSystems)
287
+ .where(eq(moduleSystems.moduleId, moduleId))
288
+ .all()
289
+ .find((r) => r.cpu != null || r.memory != null || r.disk != null);
290
+
268
291
  const resourceMappings: Array<{
269
292
  manifestKey: keyof typeof systemResources;
270
293
  configKey: string;
294
+ systemValue: number | null | undefined;
271
295
  }> = [
272
- { manifestKey: 'cpu', configKey: 'cores' }, // manifest.requires.system.cpu → cores variable
273
- { manifestKey: 'memory', configKey: 'memory' },
274
- { manifestKey: 'disk', configKey: 'disk' },
275
- { manifestKey: 'storage', configKey: 'storage' },
296
+ { manifestKey: 'cpu', configKey: 'cores', systemValue: sizedRow?.cpu }, // requires.system.cpu → cores
297
+ { manifestKey: 'memory', configKey: 'memory', systemValue: sizedRow?.memory },
298
+ { manifestKey: 'disk', configKey: 'disk', systemValue: sizedRow?.disk },
299
+ { manifestKey: 'storage', configKey: 'storage', systemValue: undefined }, // pool name, not sizing
276
300
  ];
277
301
 
278
- for (const { manifestKey, configKey } of resourceMappings) {
302
+ for (const { manifestKey, configKey, systemValue } of resourceMappings) {
303
+ if (systemValue != null) {
304
+ // Canonical system size — always wins so a resize propagates.
305
+ upsertModuleConfig(db, moduleId, configKey, systemValue);
306
+ selfConfig[configKey] = String(systemValue);
307
+ continue;
308
+ }
279
309
  const value = systemResources[manifestKey];
280
-
281
310
  // Manifest fields are typed (cpu: number, storage: string, etc.).
282
311
  // Pass them through unstringified so valueJson preserves the
283
312
  // shape — see comment in the variable-defaults block above.