@celilo/cli 0.4.1 → 0.5.0-alpha.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.4.1",
3
+ "version": "0.5.0-alpha.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,7 +6,7 @@
6
6
  import { eq } from 'drizzle-orm';
7
7
  import { getDb } from '../../db/client';
8
8
  import { modules } from '../../db/schema';
9
- import type { ModuleManifest } from '../../manifest/schema';
9
+ import { type ModuleManifest, getSingularSystemSpec } from '../../manifest/schema';
10
10
  import { buildResolutionContext } from '../../variables/context';
11
11
  import { getArg, validateRequiredArgs } from '../parser';
12
12
  import type { CommandResult } from '../types';
@@ -60,7 +60,7 @@ export async function handleModuleShowConfig(args: string[]): Promise<CommandRes
60
60
 
61
61
  for (const [key, value] of Object.entries(context.selfConfig)) {
62
62
  // Skip internal/derived keys that aren't useful for display
63
- if (key.startsWith('requires.machine.')) continue;
63
+ if (key.startsWith('requires.system.')) continue;
64
64
 
65
65
  const line = ` ${key} = ${value}`;
66
66
 
@@ -152,7 +152,7 @@ export async function handleModuleShowZone(args: string[]): Promise<CommandResul
152
152
  const context = await buildResolutionContext(moduleId, db);
153
153
  const manifest = module.manifestData as ModuleManifest;
154
154
 
155
- const zone = context.selfConfig.zone || manifest.requires?.machine?.zone;
155
+ const zone = context.selfConfig.zone || getSingularSystemSpec(manifest)?.zone;
156
156
 
157
157
  if (!zone) {
158
158
  return {
@@ -9,7 +9,7 @@ import { join } from 'node:path';
9
9
  import { eq } from 'drizzle-orm';
10
10
  import { getDb } from '../../db/client';
11
11
  import { capabilities, moduleConfigs, modules, systemConfig, systemSecrets } from '../../db/schema';
12
- import type { ModuleManifest } from '../../manifest/schema';
12
+ import { type ModuleManifest, getSingularSystemSpec } from '../../manifest/schema';
13
13
  import type { CommandResult } from '../types';
14
14
 
15
15
  /**
@@ -131,7 +131,7 @@ export async function handleStatus(): Promise<CommandResult> {
131
131
  lines.push(` Status: ${statusInfo.status}`);
132
132
 
133
133
  // Type: VPS or Container
134
- const zone = manifest.requires?.machine?.zone;
134
+ const zone = getSingularSystemSpec(manifest)?.zone;
135
135
  if (zone) {
136
136
  lines.push(` Type: Container (${zone.toUpperCase()})`);
137
137
 
@@ -368,13 +368,24 @@ export async function loadCapabilityFunctions(
368
368
  // otherwise race the async reconcile.
369
369
  onRoutesChanged: async () => {
370
370
  const reconcile = await emitWebRoutesChangedAndWait(consumingModuleId);
371
+ // Authoritative (ISS-0081): a route the provider (caddy) never
372
+ // reconciled is a deploy FAILURE, not a warning. These used to be
373
+ // logger.warn while register_route returned success anyway, so a
374
+ // consumer could report "ready" while caddy never learned the
375
+ // hostname — no site block, no cert, TLS internal_error for clients.
376
+ if (reconcile.noDispatcher) {
377
+ throw new Error(
378
+ `public_web route for ${consumingModuleId} was persisted but NOT delivered to the provider (caddy): no event dispatcher is running, so caddy never reconciled and the hostname has no site block or cert. Run this through \`celilo module deploy\` (which runs the dispatcher) rather than a bare hook.`,
379
+ );
380
+ }
371
381
  if (reconcile.timedOut) {
372
- logger.warn(
373
- `public_web reconcile for ${consumingModuleId} did not finish within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} change event(s)); the route is persisted and the provider will reconcile on its next run`,
382
+ throw new Error(
383
+ `public_web reconcile for ${consumingModuleId} did not finish within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} change event(s)) caddy did not confirm the route is live.`,
374
384
  );
375
- } else if (reconcile.failed > 0) {
376
- logger.warn(
377
- `public_web reconcile for ${consumingModuleId}: ${reconcile.failed} delivery(ies) failed; the route is persisted but the provider config may be stale`,
385
+ }
386
+ if (reconcile.failed > 0) {
387
+ throw new Error(
388
+ `public_web reconcile for ${consumingModuleId}: ${reconcile.failed} delivery(ies) failed — caddy could not apply the route, so the hostname is not served.`,
378
389
  );
379
390
  }
380
391
  },
@@ -306,7 +306,7 @@ export const UpstreamPublishHookSchema = z.object({
306
306
  * Machine resource recommendations
307
307
  * Module declares recommended machine resources (CPU, memory, disk, storage)
308
308
  */
309
- export const MachineResourceSchema = z.object({
309
+ export const SystemResourceSchema = z.object({
310
310
  cpu: z.number().int().positive().optional().describe('Recommended CPU cores'),
311
311
  memory: z.number().int().positive().optional().describe('Recommended memory in MB'),
312
312
  disk: z.number().int().positive().optional().describe('Recommended disk size in GB'),
@@ -328,7 +328,7 @@ export const SystemDeclarationSchema = z.object({
328
328
  .string()
329
329
  .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'system name must be kebab-case')
330
330
  .describe('Stable handle for this system; used in $infra:<name> and as the per-system key'),
331
- resources: MachineResourceSchema,
331
+ resources: SystemResourceSchema,
332
332
  });
333
333
 
334
334
  /**
@@ -439,17 +439,17 @@ export const ModuleManifestSchema = z
439
439
  capabilities: z.array(CapabilityRequirementSchema).default([]),
440
440
  /**
441
441
  * The 0..N systems this module deploys (v2/MODULE_SYSTEMS_ADDRESSING.md).
442
- * Preferred over the legacy singular `machine`. A module declaring
442
+ * Preferred over the singular `system`. A module declaring
443
443
  * `systems` gets one host per entry, each addressable as `$infra:<name>`.
444
444
  */
445
445
  systems: z.array(SystemDeclarationSchema).optional(),
446
446
  /**
447
- * Legacy singular form — sugar for a single unnamed system. Normalized
448
- * to one `systems` entry (name `main`) by `getDeclaredSystems`. Optional
449
- * because config-only providers (e.g. namecheap) deploy no infrastructure.
450
- * New modules should prefer `systems`.
447
+ * Singular form — sugar for a single unnamed system. Normalized to one
448
+ * `systems` entry (name `main`) by `getDeclaredSystems`. Optional because
449
+ * config-only providers (e.g. namecheap) deploy no infrastructure.
450
+ * Modules declaring more than one host should use `systems`.
451
451
  */
452
- machine: MachineResourceSchema.optional(),
452
+ system: SystemResourceSchema.optional(),
453
453
  })
454
454
  .default({ capabilities: [] }),
455
455
 
@@ -730,25 +730,36 @@ export type LifecycleHook = z.infer<typeof LifecycleHookSchema>;
730
730
  export type VariableSource = z.infer<typeof VariableSourceSchema>;
731
731
  export type VariableType = z.infer<typeof VariableTypeSchema>;
732
732
  export type AnsibleCollection = z.infer<typeof AnsibleCollectionSchema>;
733
- export type MachineResource = z.infer<typeof MachineResourceSchema>;
733
+ export type SystemResource = z.infer<typeof SystemResourceSchema>;
734
734
  export type SystemDeclaration = z.infer<typeof SystemDeclarationSchema>;
735
735
  export type BaseModuleAspect = NonNullable<ModuleManifest['base_module_aspect']>;
736
736
  export type BaseModuleAspectTrigger = BaseModuleAspect['triggers'][number];
737
737
 
738
738
  /**
739
739
  * Normalize a manifest's declared systems (v2/MODULE_SYSTEMS_ADDRESSING.md).
740
- * Returns `requires.systems` if present; else the legacy singular
741
- * `requires.machine` as one entry named `main`; else `[]` (config-only module
742
- * like namecheap). This is the single place the machine→systems sugar lives, so
743
- * the rest of the codebase only ever reasons about the 0..N collection.
740
+ * Returns `requires.systems` if present; else the singular `requires.system`
741
+ * as one entry named `main`; else `[]` (config-only module like namecheap).
742
+ * This is the single place the system→systems sugar lives, so the rest of the
743
+ * codebase only ever reasons about the 0..N collection.
744
744
  */
745
745
  export function getDeclaredSystems(manifest: ModuleManifest): SystemDeclaration[] {
746
746
  const requires = manifest.requires;
747
747
  if (requires?.systems && requires.systems.length > 0) {
748
748
  return requires.systems;
749
749
  }
750
- if (requires?.machine) {
751
- return [{ name: 'main', resources: requires.machine }];
750
+ const singular = getSingularSystemSpec(manifest);
751
+ if (singular) {
752
+ return [{ name: 'main', resources: singular }];
752
753
  }
753
754
  return [];
754
755
  }
756
+
757
+ /**
758
+ * The singular system resource spec a module declares: `requires.system`. The
759
+ * one place callers read the single-system spec (zone, sizing, "does this module
760
+ * need infra?"). Returns undefined for config-only modules or those declaring the
761
+ * plural `requires.systems`.
762
+ */
763
+ export function getSingularSystemSpec(manifest: ModuleManifest): SystemResource | undefined {
764
+ return manifest.requires?.system;
765
+ }
@@ -14,7 +14,7 @@ function createTestManifest(overrides?: Partial<ModuleManifest>): ModuleManifest
14
14
  description: 'Test module',
15
15
  requires: {
16
16
  capabilities: [],
17
- machine: {
17
+ system: {
18
18
  cpu: 2,
19
19
  memory: 2048,
20
20
  disk: 20,
@@ -54,16 +54,16 @@ function createTestManifest(overrides?: Partial<ModuleManifest>): ModuleManifest
54
54
 
55
55
  describe('template-validator', () => {
56
56
  describe('validateModuleTemplates - valid references', () => {
57
- test('validates nested manifest paths (requires.machine.*)', async () => {
57
+ test('validates nested manifest paths (requires.system.*)', async () => {
58
58
  const manifest = createTestManifest();
59
59
 
60
- // Test that requires.machine.cpu is valid
61
- const machine = manifest.requires.machine;
60
+ // Test that requires.system.cpu is valid
61
+ const system = manifest.requires.system;
62
62
 
63
- expect(machine?.cpu).toBe(2);
64
- expect(machine?.memory).toBe(2048);
65
- expect(machine?.disk).toBe(20);
66
- expect(machine?.storage).toBe('local-lvm');
63
+ expect(system?.cpu).toBe(2);
64
+ expect(system?.memory).toBe(2048);
65
+ expect(system?.disk).toBe(20);
66
+ expect(system?.storage).toBe('local-lvm');
67
67
  });
68
68
 
69
69
  test('validates capability references', async () => {
@@ -26,7 +26,7 @@ export interface TemplateValidationResult {
26
26
  * Returns undefined if path doesn't exist
27
27
  *
28
28
  * @param obj - Object to traverse
29
- * @param path - Dot-separated path (e.g., 'requires.machine.cpu')
29
+ * @param path - Dot-separated path (e.g., 'requires.system.cpu')
30
30
  * @returns Value if found, undefined otherwise
31
31
  */
32
32
  function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
@@ -50,7 +50,7 @@ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
50
50
  * Check if a path exists in the manifest
51
51
  *
52
52
  * @param manifest - Module manifest
53
- * @param path - Dot-separated path (e.g., 'requires.machine.cpu')
53
+ * @param path - Dot-separated path (e.g., 'requires.system.cpu')
54
54
  * @returns True if path exists in manifest
55
55
  */
56
56
  function pathExistsInManifest(manifest: ModuleManifest, path: string): boolean {
@@ -126,7 +126,7 @@ function validateSelfVariable(
126
126
  return `Self variable '${path}' not found in module configuration`;
127
127
  }
128
128
 
129
- // Check if it's a nested path (e.g., $self:requires.machine.cpu)
129
+ // Check if it's a nested path (e.g., $self:requires.system.cpu)
130
130
  if (pathExistsInManifest(manifest, path)) {
131
131
  return null; // Valid
132
132
  }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
+ import { getSingularSystemSpec } from './schema';
2
3
  import {
3
4
  validateCapabilityNames,
4
5
  validateCapabilityRequirements,
@@ -42,7 +43,7 @@ description: HomeKit bridge for smart home devices
42
43
 
43
44
  requires:
44
45
  capabilities: []
45
- machine:
46
+ system:
46
47
  cpu: 1
47
48
  memory: 1024
48
49
  disk: 20
@@ -71,8 +72,8 @@ variables:
71
72
  if (result.success) {
72
73
  expect(result.data.id).toBe('homebridge');
73
74
  expect(result.data.variables.owns).toHaveLength(2);
74
- expect(result.data.requires.machine?.cpu).toBe(1);
75
- expect(result.data.requires.machine?.zone).toBe('app');
75
+ expect(result.data.requires.system?.cpu).toBe(1);
76
+ expect(result.data.requires.system?.zone).toBe('app');
76
77
  }
77
78
  });
78
79
 
@@ -442,7 +443,7 @@ build:
442
443
  }
443
444
  });
444
445
 
445
- test('should validate manifest with VM resource recommendations under requires.machine', () => {
446
+ test('should validate manifest with VM resources under requires.system (canonical name)', () => {
446
447
  const yaml = `
447
448
  ${CONTRACT_LINE}
448
449
  id: grafana
@@ -451,7 +452,7 @@ version: 1.0.0
451
452
 
452
453
  requires:
453
454
  capabilities: []
454
- machine:
455
+ system:
455
456
  cpu: 2
456
457
  memory: 2048
457
458
  disk: 20
@@ -463,11 +464,11 @@ requires:
463
464
 
464
465
  expect(result.success).toBe(true);
465
466
  if (result.success) {
466
- expect(result.data.requires.machine?.cpu).toBe(2);
467
- expect(result.data.requires.machine?.memory).toBe(2048);
468
- expect(result.data.requires.machine?.disk).toBe(20);
469
- expect(result.data.requires.machine?.storage).toBe('local-lvm');
470
- expect(result.data.requires.machine?.zone).toBe('app');
467
+ expect(result.data.requires.system?.cpu).toBe(2);
468
+ expect(result.data.requires.system?.memory).toBe(2048);
469
+ expect(result.data.requires.system?.zone).toBe('app');
470
+ // getSingularSystemSpec resolves the canonical field…
471
+ expect(getSingularSystemSpec(result.data)?.cpu).toBe(2);
471
472
  }
472
473
  });
473
474
 
@@ -480,7 +481,7 @@ version: 1.0.0
480
481
 
481
482
  requires:
482
483
  capabilities: []
483
- machine:
484
+ system:
484
485
  zone: dmz
485
486
  `;
486
487
 
@@ -488,9 +489,9 @@ requires:
488
489
 
489
490
  expect(result.success).toBe(true);
490
491
  if (result.success) {
491
- expect(result.data.requires.machine?.zone).toBe('dmz');
492
- expect(result.data.requires.machine?.cpu).toBeUndefined();
493
- expect(result.data.requires.machine?.memory).toBeUndefined();
492
+ expect(result.data.requires.system?.zone).toBe('dmz');
493
+ expect(result.data.requires.system?.cpu).toBeUndefined();
494
+ expect(result.data.requires.system?.memory).toBeUndefined();
494
495
  }
495
496
  });
496
497
 
@@ -503,7 +504,7 @@ version: 1.0.0
503
504
 
504
505
  requires:
505
506
  capabilities: []
506
- machine:
507
+ system:
507
508
  cpu: -2
508
509
  zone: app
509
510
  `;
@@ -521,7 +522,7 @@ version: 1.0.0
521
522
 
522
523
  requires:
523
524
  capabilities: []
524
- machine:
525
+ system:
525
526
  zone: invalid
526
527
  `;
527
528
 
@@ -538,7 +539,7 @@ version: 1.0.0
538
539
 
539
540
  requires:
540
541
  capabilities: []
541
- machine:
542
+ system:
542
543
  cpu: 2.5
543
544
  zone: app
544
545
  `;
@@ -3,7 +3,7 @@ import { parse as parseYaml } from 'yaml';
3
3
  import type { ZodError } from 'zod';
4
4
  import { validateModuleZoneRequirements } from '../services/zone-policy';
5
5
  import { resolveContract, supportedContractVersions } from './contracts';
6
- import { ModuleManifestSchema } from './schema';
6
+ import { ModuleManifestSchema, getSingularSystemSpec } from './schema';
7
7
  import type { ModuleManifest } from './schema';
8
8
 
9
9
  /**
@@ -304,16 +304,16 @@ export function validateVariableSources(manifest: ModuleManifest): ValidationErr
304
304
  export function validateZoneRequirements(manifest: ModuleManifest): ValidationError | null {
305
305
  const errors: Array<{ path: string; message: string }> = [];
306
306
 
307
- // Check if module has infrastructure spec (requires.machine)
308
- const hasInfrastructureSpec = manifest.requires?.machine;
307
+ // Check if module has infrastructure spec (requires.system)
308
+ const hasInfrastructureSpec = getSingularSystemSpec(manifest);
309
309
 
310
310
  // If module requires infrastructure, zone field is mandatory
311
311
  if (hasInfrastructureSpec) {
312
- const zone = manifest.requires?.machine?.zone;
312
+ const zone = hasInfrastructureSpec.zone;
313
313
 
314
314
  if (!zone) {
315
315
  errors.push({
316
- path: 'requires.machine.zone',
316
+ path: 'requires.system.zone',
317
317
  message: 'Zone field is required for modules with infrastructure requirements',
318
318
  });
319
319
  // Can't validate zone requirements without a zone
@@ -327,7 +327,7 @@ export function validateZoneRequirements(manifest: ModuleManifest): ValidationEr
327
327
 
328
328
  if (!zoneValidation.valid && zoneValidation.error) {
329
329
  errors.push({
330
- path: 'requires.machine.zone',
330
+ path: 'requires.system.zone',
331
331
  message: zoneValidation.error,
332
332
  });
333
333
  }
@@ -10,7 +10,7 @@ import { getModuleStoragePath } from '../config/paths';
10
10
  import { type DbClient, getDb } from '../db/client';
11
11
  import { capabilities, moduleIntegrity, modules } from '../db/schema';
12
12
  import type { NewModule, NewModuleIntegrity } from '../db/schema';
13
- import type { ModuleManifest } from '../manifest/schema';
13
+ import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
14
14
  import {
15
15
  validateCapabilityNames,
16
16
  validateDeriveFromSources,
@@ -348,7 +348,7 @@ export async function validateWellKnownCapabilities(
348
348
  }
349
349
 
350
350
  // Check 2: Zone enforcement - module must be in the correct zone
351
- const moduleZone = manifest.requires?.machine?.zone;
351
+ const moduleZone = getSingularSystemSpec(manifest)?.zone;
352
352
 
353
353
  if (wellKnown.zone_enforced && moduleZone) {
354
354
  if (moduleZone !== wellKnown.required_zone) {
@@ -217,7 +217,13 @@ describe('celilo lifecycle events', () => {
217
217
  it('returns immediately when the deploy changed no routes', async () => {
218
218
  const since = routesChangedHighWater();
219
219
  const result = await waitForRouteReconcile(since, { timeoutMs: 1000 });
220
- expect(result).toEqual({ events: 0, succeeded: 0, failed: 0, timedOut: false });
220
+ expect(result).toEqual({
221
+ events: 0,
222
+ succeeded: 0,
223
+ failed: 0,
224
+ timedOut: false,
225
+ noDispatcher: false,
226
+ });
221
227
  });
222
228
 
223
229
  it('skips the wait (no time-out) when no dispatcher is running', async () => {
@@ -226,7 +232,13 @@ describe('celilo lifecycle events', () => {
226
232
  emitWebRoutesChanged('celilo-website');
227
233
  // No heartbeat stamped → health() is no_dispatcher.
228
234
  const result = await waitForRouteReconcile(since, { timeoutMs: 5000, pollMs: 50 });
229
- expect(result).toEqual({ events: 1, succeeded: 0, failed: 0, timedOut: false });
235
+ expect(result).toEqual({
236
+ events: 1,
237
+ succeeded: 0,
238
+ failed: 0,
239
+ timedOut: false,
240
+ noDispatcher: true,
241
+ });
230
242
  });
231
243
 
232
244
  it('settles immediately when no provider subscribes (zero deliveries)', async () => {
@@ -234,7 +246,13 @@ describe('celilo lifecycle events', () => {
234
246
  const since = routesChangedHighWater();
235
247
  emitWebRoutesChanged('celilo-website');
236
248
  const result = await waitForRouteReconcile(since, { timeoutMs: 1000, pollMs: 50 });
237
- expect(result).toEqual({ events: 1, succeeded: 0, failed: 0, timedOut: false });
249
+ expect(result).toEqual({
250
+ events: 1,
251
+ succeeded: 0,
252
+ failed: 0,
253
+ timedOut: false,
254
+ noDispatcher: false,
255
+ });
238
256
  });
239
257
 
240
258
  it('times out while the provider delivery is still pending', async () => {
@@ -255,7 +273,13 @@ describe('celilo lifecycle events', () => {
255
273
  settleDelivery('succeed');
256
274
 
257
275
  const result = await waitForRouteReconcile(since, { timeoutMs: 1000, pollMs: 50 });
258
- expect(result).toEqual({ events: 1, succeeded: 1, failed: 0, timedOut: false });
276
+ expect(result).toEqual({
277
+ events: 1,
278
+ succeeded: 1,
279
+ failed: 0,
280
+ timedOut: false,
281
+ noDispatcher: false,
282
+ });
259
283
  });
260
284
 
261
285
  it('reports a failed delivery without timing out', async () => {
@@ -284,7 +308,13 @@ describe('celilo lifecycle events', () => {
284
308
  settleDelivery('succeed');
285
309
 
286
310
  const result = await pending;
287
- expect(result).toEqual({ events: 1, succeeded: 1, failed: 0, timedOut: false });
311
+ expect(result).toEqual({
312
+ events: 1,
313
+ succeeded: 1,
314
+ failed: 0,
315
+ timedOut: false,
316
+ noDispatcher: false,
317
+ });
288
318
  });
289
319
  });
290
320
  });
@@ -222,6 +222,13 @@ export interface RouteReconcileWaitResult {
222
222
  succeeded: number;
223
223
  failed: number;
224
224
  timedOut: boolean;
225
+ /**
226
+ * True when no event dispatcher was running, so the `routes_changed` event
227
+ * was persisted but never DELIVERED to the provider (caddy). The route is
228
+ * therefore NOT live. Authoritative callers (ISS-0081) must treat this as a
229
+ * failure, not a benign "0 deliveries" success.
230
+ */
231
+ noDispatcher: boolean;
225
232
  }
226
233
 
227
234
  /**
@@ -283,14 +290,20 @@ export async function waitForRouteReconcile(
283
290
  .filter((e) => e.id > sinceEventId);
284
291
 
285
292
  if (events.length === 0) {
286
- return { events: 0, succeeded: 0, failed: 0, timedOut: false };
293
+ return { events: 0, succeeded: 0, failed: 0, timedOut: false, noDispatcher: false };
287
294
  }
288
295
 
289
296
  if (b.health().status === 'no_dispatcher') {
290
297
  console.warn(
291
298
  `[celilo] route-reconcile: no dispatcher running — not waiting; ${events.length} change event(s) persisted, the provider will reconcile on its next run`,
292
299
  );
293
- return { events: events.length, succeeded: 0, failed: 0, timedOut: false };
300
+ return {
301
+ events: events.length,
302
+ succeeded: 0,
303
+ failed: 0,
304
+ timedOut: false,
305
+ noDispatcher: true,
306
+ };
294
307
  }
295
308
 
296
309
  while (true) {
@@ -306,6 +319,7 @@ export async function waitForRouteReconcile(
306
319
  succeeded: settled('succeeded'),
307
320
  failed: settled('failed') + settled('abandoned'),
308
321
  timedOut: pending > 0,
322
+ noDispatcher: false,
309
323
  };
310
324
  }
311
325
 
@@ -314,7 +328,7 @@ export async function waitForRouteReconcile(
314
328
  } catch (err) {
315
329
  const msg = err instanceof Error ? err.message : String(err);
316
330
  console.warn(`[celilo] route-reconcile wait errored: ${msg}`);
317
- return { events: 0, succeeded: 0, failed: 0, timedOut: false };
331
+ return { events: 0, succeeded: 0, failed: 0, timedOut: false, noDispatcher: false };
318
332
  } finally {
319
333
  bus?.close();
320
334
  }
@@ -152,14 +152,18 @@ describe('celilo-mgmt on_backup', () => {
152
152
  expect(result.size_bytes).toBeGreaterThan(0);
153
153
  });
154
154
 
155
- it('captures module SOURCE (excluding generated/) into module_src/', async () => {
155
+ it('captures LEAN module source (excludes generated/, node_modules/, large build artifacts)', async () => {
156
156
  // stateDir = dirname(db_path) = dir; on_backup reads dir/modules/<id>.
157
157
  const modSrc = join(dir, 'modules', 'caddy');
158
- mkdirSync(join(modSrc, 'scripts'), { recursive: true });
158
+ mkdirSync(join(modSrc, 'scripts', 'node_modules', '@celilo'), { recursive: true });
159
159
  mkdirSync(join(modSrc, 'generated', 'terraform'), { recursive: true });
160
+ mkdirSync(join(modSrc, 'ansible', 'files'), { recursive: true });
160
161
  writeFileSync(join(modSrc, 'manifest.yml'), 'id: caddy');
161
162
  writeFileSync(join(modSrc, 'scripts', 'hook.ts'), '// hook');
162
163
  writeFileSync(join(modSrc, 'generated', 'terraform', 'main.tf'), 'resource {}');
164
+ writeFileSync(join(modSrc, 'scripts', 'node_modules', '@celilo', 'dep.js'), '// vendored');
165
+ // A >2MB "compiled binary" sitting in source — must be skipped by size.
166
+ writeFileSync(join(modSrc, 'ansible', 'files', 'server-bin'), Buffer.alloc(3 * 1024 * 1024));
163
167
 
164
168
  const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
165
169
  await hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }));
@@ -167,8 +171,15 @@ describe('celilo-mgmt on_backup', () => {
167
171
  // Source files captured...
168
172
  expect(existsSync(join(backupDir, 'module_src', 'caddy', 'manifest.yml'))).toBe(true);
169
173
  expect(existsSync(join(backupDir, 'module_src', 'caddy', 'scripts', 'hook.ts'))).toBe(true);
170
- // ...but generated/ excluded (TF state travels via cross_module_state).
174
+ // ...but build/vendored content excluded (keeps the backup small enough to
175
+ // not OOM the in-memory tar+encrypt — turnip's full dirs were ~1.6GB).
171
176
  expect(existsSync(join(backupDir, 'module_src', 'caddy', 'generated'))).toBe(false);
177
+ expect(existsSync(join(backupDir, 'module_src', 'caddy', 'scripts', 'node_modules'))).toBe(
178
+ false,
179
+ );
180
+ expect(
181
+ existsSync(join(backupDir, 'module_src', 'caddy', 'ansible', 'files', 'server-bin')),
182
+ ).toBe(false);
172
183
  });
173
184
 
174
185
  it('machine-pool.json is valid JSON (array)', async () => {
@@ -26,7 +26,7 @@ import { eq } from 'drizzle-orm';
26
26
  import type { DbClient } from '../db/client';
27
27
  import { getDb } from '../db/client';
28
28
  import { capabilities, moduleConfigs, modules, secrets } from '../db/schema';
29
- import type { ModuleManifest } from '../manifest/schema';
29
+ import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
30
30
  import { buildResolutionContext } from '../variables/context';
31
31
  import { E2E_CONFLICT_FIX, runningE2eContainers } from './e2e-guard';
32
32
 
@@ -241,8 +241,9 @@ export async function runPreflight(
241
241
  }
242
242
 
243
243
  // 5. Infrastructure availability (for modules that need it)
244
- if (manifest.requires?.machine) {
245
- const zone = manifest.requires.machine.zone;
244
+ const systemSpec = getSingularSystemSpec(manifest);
245
+ if (systemSpec) {
246
+ const zone = systemSpec.zone;
246
247
  if (zone) {
247
248
  // Simplified check: is there a machine or service for the module's zone?
248
249
  // The full infrastructure selector (selectInfrastructure) needs a Module
@@ -60,7 +60,7 @@ function seedProxmoxModule(
60
60
  id: opts.id,
61
61
  name: opts.id,
62
62
  version: '1.0.0',
63
- manifestData: { requires: { machine: { zone: opts.zone } } },
63
+ manifestData: { requires: { system: { zone: opts.zone } } },
64
64
  sourcePath: `/tmp/${opts.id}`,
65
65
  state: 'VERIFIED',
66
66
  })
@@ -180,7 +180,7 @@ describe('backfillModuleSystems', () => {
180
180
  id: 'technitium',
181
181
  name: 'technitium',
182
182
  version: '1.0.0',
183
- manifestData: { requires: { machine: { zone: 'internal' } } },
183
+ manifestData: { requires: { system: { zone: 'internal' } } },
184
184
  sourcePath: '/tmp/technitium',
185
185
  state: 'VERIFIED',
186
186
  })
@@ -150,7 +150,7 @@ function asZone(value: string | undefined): NetworkZone | null {
150
150
  * single sink the old scattered `target_ip` writes collapse into.
151
151
  *
152
152
  * Transition: every current module declares exactly one system (via
153
- * `requires.machine`, normalized to name `main`, or one `requires.systems`
153
+ * `requires.system`, normalized to name `main`, or one `requires.systems`
154
154
  * entry), so this records that single host from `hostname` config + the
155
155
  * resolved IP. Returns the systems it recorded ([] for an API-only module with
156
156
  * no host — e.g. namecheap). See v2/MODULE_SYSTEMS_ADDRESSING.md.
@@ -61,7 +61,7 @@ describe('infrastructure-selector', () => {
61
61
  version: '1.0.0',
62
62
  manifestData: {
63
63
  requires: {
64
- machine: {
64
+ system: {
65
65
  cpu: 2,
66
66
  memory: 2048,
67
67
  disk: 20,
@@ -119,7 +119,7 @@ describe('infrastructure-selector', () => {
119
119
  version: '1.0.0',
120
120
  manifestData: {
121
121
  requires: {
122
- machine: {
122
+ system: {
123
123
  cpu: 2,
124
124
  memory: 2048,
125
125
  disk: 20,
@@ -162,7 +162,7 @@ describe('infrastructure-selector', () => {
162
162
  version: '1.0.0',
163
163
  manifestData: {
164
164
  requires: {
165
- machine: {
165
+ system: {
166
166
  cpu: 2,
167
167
  memory: 2048,
168
168
  disk: 20,
@@ -197,7 +197,7 @@ describe('infrastructure-selector', () => {
197
197
  const module = (await db.select().from(modules).limit(1))[0] as Module;
198
198
 
199
199
  await expect(selectInfrastructure(module)).rejects.toThrow(InfrastructureError);
200
- await expect(selectInfrastructure(module)).rejects.toThrow(/missing requires.machine/);
200
+ await expect(selectInfrastructure(module)).rejects.toThrow(/missing requires.system/);
201
201
  });
202
202
 
203
203
  it('throws error when module manifest missing zone', async () => {
@@ -210,7 +210,7 @@ describe('infrastructure-selector', () => {
210
210
  version: '1.0.0',
211
211
  manifestData: {
212
212
  requires: {
213
- machine: {
213
+ system: {
214
214
  cpu: 2,
215
215
  memory: 2048,
216
216
  disk: 20,
@@ -224,20 +224,20 @@ describe('infrastructure-selector', () => {
224
224
  const module = (await db.select().from(modules).limit(1))[0] as Module;
225
225
 
226
226
  await expect(selectInfrastructure(module)).rejects.toThrow(InfrastructureError);
227
- await expect(selectInfrastructure(module)).rejects.toThrow(/missing requires.machine.zone/);
227
+ await expect(selectInfrastructure(module)).rejects.toThrow(/missing requires.system.zone/);
228
228
  });
229
229
 
230
- it('supports requires.machine format', async () => {
230
+ it('supports requires.system format', async () => {
231
231
  const db = createDbClient({ path: testDbPath });
232
232
 
233
- // Create test module with requires.machine format
233
+ // Create test module with requires.system format
234
234
  await db.insert(modules).values({
235
235
  id: 'test-module',
236
236
  name: 'Test Module',
237
237
  version: '1.0.0',
238
238
  manifestData: {
239
239
  requires: {
240
- machine: {
240
+ system: {
241
241
  cpu: 2,
242
242
  memory: 2048,
243
243
  disk: 20,
@@ -284,7 +284,7 @@ describe('infrastructure-selector', () => {
284
284
  version: '1.0.0',
285
285
  manifestData: {
286
286
  requires: {
287
- machine: {
287
+ system: {
288
288
  cpu: 2,
289
289
  memory: 2048,
290
290
  disk: 20,
@@ -326,7 +326,7 @@ describe('infrastructure-selector', () => {
326
326
  version: '1.0.0',
327
327
  manifestData: {
328
328
  requires: {
329
- machine: {
329
+ system: {
330
330
  cpu: 2,
331
331
  memory: 2048,
332
332
  disk: 20,
@@ -366,7 +366,7 @@ describe('infrastructure-selector', () => {
366
366
  version: '1.0.0',
367
367
  manifestData: {
368
368
  requires: {
369
- machine: {
369
+ system: {
370
370
  cpu: 2,
371
371
  memory: 2048,
372
372
  disk: 20,
@@ -406,7 +406,7 @@ describe('infrastructure-selector', () => {
406
406
  version: '1.0.0',
407
407
  manifestData: {
408
408
  requires: {
409
- machine: {
409
+ system: {
410
410
  cpu: 2,
411
411
  memory: 2048,
412
412
  disk: 20,
@@ -449,7 +449,7 @@ describe('infrastructure-selector', () => {
449
449
  version: '1.0.0',
450
450
  manifestData: {
451
451
  requires: {
452
- machine: {
452
+ system: {
453
453
  cpu: 4,
454
454
  memory: 4096,
455
455
  disk: 128,
@@ -1,5 +1,5 @@
1
1
  import type { Module } from '../db/schema';
2
- import type { ModuleManifest } from '../manifest/schema';
2
+ import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
3
3
  import type {
4
4
  InfrastructureSelection,
5
5
  Machine,
@@ -25,27 +25,26 @@ export class InfrastructureError extends Error {
25
25
  function getResourceRequirements(module: Module): ResourceRequirements {
26
26
  const manifest = module.manifestData as ModuleManifest;
27
27
 
28
- // Check for requires.machine
29
- const machine = manifest.requires?.machine;
28
+ const system = getSingularSystemSpec(manifest);
30
29
 
31
- if (!machine) {
30
+ if (!system) {
32
31
  throw new InfrastructureError(
33
- `Module ${module.id} manifest missing requires.machine configuration`,
32
+ `Module ${module.id} manifest missing requires.system configuration`,
34
33
  );
35
34
  }
36
35
 
37
- if (!machine.zone) {
36
+ if (!system.zone) {
38
37
  throw new InfrastructureError(
39
- `Module ${module.id} manifest missing requires.machine.zone field`,
38
+ `Module ${module.id} manifest missing requires.system.zone field`,
40
39
  );
41
40
  }
42
41
 
43
42
  return {
44
- cpu: machine.cpu ?? 1,
45
- memory: machine.memory ?? 1024,
46
- disk: machine.disk ?? 10,
47
- storage: machine.storage,
48
- zone: machine.zone,
43
+ cpu: system.cpu ?? 1,
44
+ memory: system.memory ?? 1024,
45
+ disk: system.disk ?? 10,
46
+ storage: system.storage,
47
+ zone: system.zone,
49
48
  };
50
49
  }
51
50
 
@@ -16,7 +16,7 @@ import { loadCapabilityFunctions } from '../hooks/capability-loader';
16
16
  import { invokeHook } from '../hooks/executor';
17
17
  import { createGaugeLogger } from '../hooks/logger';
18
18
  import type { HookDefinition, HookLogger, HookResult } from '../hooks/types';
19
- import type { ModuleManifest } from '../manifest/schema';
19
+ import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
20
20
  import { decryptSecret } from '../secrets/encryption';
21
21
  import { getOrCreateMasterKey } from '../secrets/master-key';
22
22
  import { buildResolutionContext } from '../variables/context';
@@ -416,7 +416,7 @@ async function deployModuleImpl(
416
416
  );
417
417
  if (machineDerivable.length > 0) {
418
418
  const isFirewall = manifest.provides?.capabilities?.some((cap) => cap.name === 'firewall');
419
- const moduleZone = manifest.requires?.machine?.zone;
419
+ const moduleZone = getSingularSystemSpec(manifest)?.zone;
420
420
  const matchedMachine = await findMachineForModule(
421
421
  moduleId,
422
422
  moduleZone,
@@ -473,7 +473,7 @@ async function deployModuleImpl(
473
473
  if (regularConfig.length > 0) {
474
474
  // Look up machine for $machine: derivation (earmarked or best match from pool)
475
475
  const isFirewall = manifest.provides?.capabilities?.some((cap) => cap.name === 'firewall');
476
- const moduleZone = manifest.requires?.machine?.zone;
476
+ const moduleZone = getSingularSystemSpec(manifest)?.zone;
477
477
  const matchedMachine = await findMachineForModule(
478
478
  moduleId,
479
479
  moduleZone,
@@ -668,7 +668,7 @@ async function deployModuleImpl(
668
668
  }
669
669
 
670
670
  // Config-only modules (no infrastructure requirements) don't need terraform/ansible
671
- const isConfigOnly = !manifest.requires?.machine;
671
+ const isConfigOnly = !getSingularSystemSpec(manifest);
672
672
  if (isConfigOnly) {
673
673
  log.success('Config-only module — no infrastructure deployment needed');
674
674
 
@@ -208,10 +208,21 @@ export async function restoreFromArtifactFile(
208
208
  hookInputs.cross_module_write_root = crossModuleWriteRoot;
209
209
  }
210
210
 
211
- // 5. Invoke the hook.
211
+ // 5. Invoke the hook. Resolve the module path from getModuleStoragePath()
212
+ // rather than the stored mod.sourcePath (ISS-0052): on a box whose DB was
213
+ // produced by a prior restore from ANOTHER box, source_path is that box's
214
+ // absolute path (e.g. a macOS /Users/... path on a Linux box), and the
215
+ // hook executor's screenshot-dir mkdir then EACCES'es on it. By
216
+ // construction (import.ts) the code always lives at
217
+ // ${getModuleStoragePath()}/<id>, so resolve there. Falls back to the
218
+ // stored path only if the canonical dir is absent (e.g. a custom layout).
219
+ const canonicalModulePath = join(getModuleStoragePath(), mod.id);
220
+ const onRestoreModulePath = existsSync(canonicalModulePath)
221
+ ? canonicalModulePath
222
+ : mod.sourcePath;
212
223
  const logger = createConsoleLogger(mod.id, 'on_restore');
213
224
  const hookResult = await invokeHook(
214
- mod.sourcePath,
225
+ onRestoreModulePath,
215
226
  'on_restore',
216
227
  manifest.celilo_contract,
217
228
  hookDef,
@@ -57,8 +57,14 @@ export function validateTerraformPlanSafety(planOutput: string): PlanSafetyResul
57
57
  };
58
58
  }
59
59
 
60
- // Rule 2: Only CREATE operations allowed
61
- if (action.action !== 'create') {
60
+ // Rule 2: CREATE and in-place UPDATE are allowed; REPLACE and DELETE are
61
+ // refused. (ISS-0055) An in-place update — the lifecycle-ignored
62
+ // computed-attribute redeploy, or a deliberate memory/cpu resize — is safe
63
+ // and must go through, so the management plane can update containers in
64
+ // place. Only destroy+recreate (replace) and delete are data-loss
65
+ // operations we hard-block. The parser matches `replace` before `update`,
66
+ // so a "must be replaced" plan is still classified as replace and refused.
67
+ if (action.action !== 'create' && action.action !== 'update') {
62
68
  return {
63
69
  safe: false,
64
70
  error: formatUnsafeOperationError(action),
@@ -101,12 +101,12 @@ export function validateModuleZoneRequirements(
101
101
  * Policy function - determines if zone validation applies
102
102
  *
103
103
  * Zone is required if:
104
- * - Module specifies requires.machine (infrastructure provisioning)
104
+ * - Module specifies requires.system (infrastructure provisioning)
105
105
  *
106
106
  * Zone is optional if:
107
107
  * - Module has no infrastructure requirements (e.g., VPS-based modules)
108
108
  *
109
- * @param hasInfrastructureSpec - Whether module has requires.machine
109
+ * @param hasInfrastructureSpec - Whether module has requires.system
110
110
  * @returns True if zone field is required
111
111
  */
112
112
  export function isZoneRequired(hasInfrastructureSpec: boolean): boolean {
@@ -495,7 +495,7 @@ resource "proxmox_lxc" "container" {
495
495
  provides: { capabilities: [] },
496
496
  requires: {
497
497
  capabilities: [],
498
- machine: {
498
+ system: {
499
499
  cpu: 1,
500
500
  memory: 1024,
501
501
  disk: 10,
@@ -650,7 +650,9 @@ resource "proxmox_lxc" "container" {
650
650
  const out = injectProxmoxLxcDns(LXC, true);
651
651
  expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
652
652
  expect(out).toContain(' lifecycle {');
653
- expect(out).toContain(' ignore_changes = [nameserver]');
653
+ expect(out).toContain(
654
+ ' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]',
655
+ );
654
656
  // Injected immediately after the opening line, before author attributes.
655
657
  const lines = out.split('\n');
656
658
  expect(lines[0]).toBe('resource "proxmox_lxc" "caddy" {');
@@ -665,7 +667,9 @@ resource "proxmox_lxc" "container" {
665
667
  const out = injectProxmoxLxcDns(LXC, false);
666
668
  expect(out).not.toContain('nameserver = "$self:lxc_nameserver"');
667
669
  expect(out).toContain(' lifecycle {');
668
- expect(out).toContain(' ignore_changes = [nameserver]');
670
+ expect(out).toContain(
671
+ ' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]',
672
+ );
669
673
  });
670
674
 
671
675
  test('is idempotent — already-injected content is returned unchanged', () => {
@@ -687,8 +691,8 @@ resource "proxmox_lxc" "container" {
687
691
  const out = injectProxmoxLxcDns(stale, true);
688
692
  // Exactly one nameserver attribute survives.
689
693
  expect(out.match(/nameserver\s*=/g)?.length).toBe(1);
690
- // The lifecycle guard is still added.
691
- expect(out).toContain('ignore_changes = [nameserver]');
694
+ // The lifecycle guard is still added (ISS-0055 extended the ignore list).
695
+ expect(out).toContain('ignore_changes = [nameserver,');
692
696
  });
693
697
 
694
698
  test('leaves non-proxmox_lxc resources untouched', () => {
@@ -699,7 +703,7 @@ resource "proxmox_lxc" "container" {
699
703
  test('injects into every proxmox_lxc block in a multi-resource file', () => {
700
704
  const two = `${LXC}\n\n${LXC.replace('"caddy"', '"forgejo"')}`;
701
705
  const out = injectProxmoxLxcDns(two, true);
702
- expect(out.match(/ignore_changes = \[nameserver\]/g)?.length).toBe(2);
706
+ expect(out.match(/ignore_changes = \[nameserver,/g)?.length).toBe(2);
703
707
  });
704
708
 
705
709
  test('matches the indentation of the resource opening line', () => {
@@ -707,7 +711,9 @@ resource "proxmox_lxc" "container" {
707
711
  const out = injectProxmoxLxcDns(indented, true);
708
712
  expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
709
713
  expect(out).toContain(' lifecycle {');
710
- expect(out).toContain(' ignore_changes = [nameserver]');
714
+ expect(out).toContain(
715
+ ' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]',
716
+ );
711
717
  });
712
718
  });
713
719
  });
@@ -15,6 +15,7 @@ import {
15
15
  moduleInfrastructure,
16
16
  modules,
17
17
  } from '../db/schema';
18
+ import { getSingularSystemSpec } from '../manifest/schema';
18
19
  import type { AnsibleCollection, ModuleManifest } from '../manifest/schema';
19
20
  import { validateZoneRequirements } from '../manifest/validate';
20
21
  import { selectInfrastructure } from '../services/infrastructure-selector';
@@ -161,7 +162,10 @@ export function getOutputFilename(templateFilename: string): string {
161
162
  */
162
163
  export function injectProxmoxLxcDns(content: string, hasNameserver: boolean): string {
163
164
  // Already injected (idempotent) or author opted into the lifecycle — done.
164
- if (content.includes('ignore_changes = [nameserver]')) {
165
+ // Match the `[nameserver` prefix (no closing bracket) so this stays true
166
+ // whether the list is the original `[nameserver]` or the ISS-0055-extended
167
+ // `[nameserver, network[0].hwaddr, …]` — otherwise re-generate double-injects.
168
+ if (content.includes('ignore_changes = [nameserver')) {
165
169
  return content;
166
170
  }
167
171
 
@@ -181,7 +185,17 @@ export function injectProxmoxLxcDns(content: string, hasNameserver: boolean): st
181
185
  injected.push(`${inner}nameserver = "$self:lxc_nameserver"`);
182
186
  }
183
187
  injected.push(`${inner}lifecycle {`);
184
- injected.push(`${inner} ignore_changes = [nameserver]`);
188
+ // ISS-0055: ignore the ForceNew attributes Proxmox assigns at create time —
189
+ // the MAC (network hwaddr) and the rootfs volume path. telmate marks these
190
+ // ForceNew, so leaving them out of the config makes every re-deploy plan a
191
+ // destructive REPLACE. Ignoring just these two makes an unchanged re-deploy a
192
+ // no-op (the computed network id/type stay stable once the block is no longer
193
+ // being replaced) and a real change (e.g. a memory bump) an in-place UPDATE.
194
+ // We deliberately do NOT list network[0].id / network[0].type — they aren't
195
+ // schema attributes in telmate ~>2.9 and `terraform validate` rejects them.
196
+ // `nameserver` stays for the original DNS reason; `rootfs.size` is NOT ignored
197
+ // so a disk grow still applies in place.
198
+ injected.push(`${inner} ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]`);
185
199
  injected.push(`${inner}}`);
186
200
  return injected.join('\n');
187
201
  });
@@ -507,9 +521,9 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
507
521
 
508
522
  // Infrastructure Selection
509
523
  // Select infrastructure for this module and store in database
510
- // Only required for modules that specify requires.machine (container-based or machine-poolbased)
524
+ // Only required for modules that specify requires.system (container-based or machine-pool-based)
511
525
  let infrastructureSelection: InfrastructureSelection | undefined;
512
- const hasResourcesSpec = manifest.requires?.machine;
526
+ const hasResourcesSpec = getSingularSystemSpec(manifest);
513
527
 
514
528
  if (hasResourcesSpec) {
515
529
  // Check if infrastructure already selected (from previous generation)
@@ -559,7 +573,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
559
573
  // IPAM Auto-Allocation
560
574
  // Only allocate for Proxmox container services (not Digital Ocean or machines)
561
575
  // Digital Ocean gets IPs from Terraform outputs; Proxmox needs local IPAM
562
- const zone = manifest.requires?.machine?.zone;
576
+ const zone = getSingularSystemSpec(manifest)?.zone;
563
577
  const hasManualVmid = manifest.variables?.owns?.some((v) => v.name === 'vmid');
564
578
  const isContainerService = infrastructureSelection?.type === 'container_service';
565
579
 
@@ -1094,7 +1108,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
1094
1108
  // Build infrastructure info for CLI output
1095
1109
  let infrastructureInfo: import('./types').InfrastructureInfo | undefined;
1096
1110
  if (infrastructureSelection) {
1097
- const zone = manifest.requires?.machine?.zone || 'unknown';
1111
+ const zone = getSingularSystemSpec(manifest)?.zone || 'unknown';
1098
1112
 
1099
1113
  if (infrastructureSelection.type === 'machine' && infrastructureSelection.machineId) {
1100
1114
  const machine = await db
@@ -358,7 +358,7 @@ describe('Variable Context', () => {
358
358
  name: 'Grafana',
359
359
  version: '1.0.0',
360
360
  requires: {
361
- machine: {
361
+ system: {
362
362
  cpu: 2,
363
363
  memory: 2048,
364
364
  disk: 20,
@@ -372,34 +372,11 @@ describe('Variable Context', () => {
372
372
 
373
373
  const context = await buildResolutionContext('grafana', db);
374
374
 
375
- expect(context.selfConfig['requires.machine.cpu']).toBe('2');
376
- expect(context.selfConfig['requires.machine.memory']).toBe('2048');
377
- expect(context.selfConfig['requires.machine.disk']).toBe('20');
378
- expect(context.selfConfig['requires.machine.storage']).toBe('local-lvm');
379
- expect(context.selfConfig['requires.machine.zone']).toBe('app');
380
- });
381
-
382
- test('should handle module without VM resources', async () => {
383
- // Create module without VM resources
384
- db.insert(modules)
385
- .values({
386
- id: 'simple',
387
- name: 'Simple Module',
388
- version: '1.0.0',
389
- sourcePath: '/test/simple',
390
- manifestData: {
391
- id: 'simple',
392
- name: 'Simple Module',
393
- version: '1.0.0',
394
- },
395
- })
396
- .run();
397
-
398
- const context = await buildResolutionContext('simple', db);
399
-
400
- // Should not have requires.machine keys
401
- expect(context.selfConfig['requires.machine.cpu']).toBeUndefined();
402
- expect(context.selfConfig['requires.machine.memory']).toBeUndefined();
375
+ expect(context.selfConfig['requires.system.cpu']).toBe('2');
376
+ expect(context.selfConfig['requires.system.memory']).toBe('2048');
377
+ expect(context.selfConfig['requires.system.disk']).toBe('20');
378
+ expect(context.selfConfig['requires.system.storage']).toBe('local-lvm');
379
+ expect(context.selfConfig['requires.system.zone']).toBe('app');
403
380
  });
404
381
 
405
382
  test('should auto-derive inventory variables from target_ip', async () => {
@@ -472,7 +449,7 @@ describe('Variable Context', () => {
472
449
  ],
473
450
  },
474
451
  requires: {
475
- machine: {
452
+ system: {
476
453
  zone: 'dmz',
477
454
  },
478
455
  },
@@ -529,7 +506,7 @@ describe('Variable Context', () => {
529
506
  { name: 'target_ip', type: 'string', required: true, source: 'user' },
530
507
  ],
531
508
  },
532
- requires: { machine: { zone: 'dmz' } },
509
+ requires: { system: { zone: 'dmz' } },
533
510
  },
534
511
  })
535
512
  .run();
@@ -649,7 +626,7 @@ describe('Variable Context', () => {
649
626
  { name: 'target_ip', type: 'string', required: true, source: 'user' },
650
627
  ],
651
628
  },
652
- requires: { machine: { zone: 'dmz' } },
629
+ requires: { system: { zone: 'dmz' } },
653
630
  },
654
631
  })
655
632
  .run();
@@ -672,7 +649,7 @@ describe('Variable Context', () => {
672
649
  { name: 'target_ip', type: 'string', required: true, source: 'user' },
673
650
  ],
674
651
  },
675
- requires: { machine: { zone: 'dmz' } },
652
+ requires: { system: { zone: 'dmz' } },
676
653
  },
677
654
  })
678
655
  .run();
@@ -890,7 +867,7 @@ describe('Variable Context', () => {
890
867
  name: 'App With Resources',
891
868
  version: '1.0.0',
892
869
  requires: {
893
- machine: {
870
+ system: {
894
871
  cpu: 2,
895
872
  memory: 2048,
896
873
  disk: 20,
@@ -935,7 +912,7 @@ describe('Variable Context', () => {
935
912
  name: 'Custom Resources',
936
913
  version: '1.0.0',
937
914
  requires: {
938
- machine: {
915
+ system: {
939
916
  cpu: 2,
940
917
  memory: 2048,
941
918
  },
@@ -997,7 +974,7 @@ describe('Variable Context', () => {
997
974
  name: 'Partial Resources',
998
975
  version: '1.0.0',
999
976
  requires: {
1000
- machine: {
977
+ system: {
1001
978
  cpu: 1,
1002
979
  memory: 512,
1003
980
  // No disk or storage specified
@@ -1031,7 +1008,7 @@ describe('Variable Context', () => {
1031
1008
  name: 'DMZ Module',
1032
1009
  version: '1.0.0',
1033
1010
  requires: {
1034
- machine: {
1011
+ system: {
1035
1012
  zone: 'dmz',
1036
1013
  },
1037
1014
  },
@@ -1073,7 +1050,7 @@ describe('Variable Context', () => {
1073
1050
  name: 'App Module',
1074
1051
  version: '1.0.0',
1075
1052
  requires: {
1076
- machine: {
1053
+ system: {
1077
1054
  zone: 'app',
1078
1055
  },
1079
1056
  },
@@ -1103,7 +1080,7 @@ describe('Variable Context', () => {
1103
1080
  name: 'Secure Module',
1104
1081
  version: '1.0.0',
1105
1082
  requires: {
1106
- machine: {
1083
+ system: {
1107
1084
  zone: 'secure',
1108
1085
  },
1109
1086
  },
@@ -1158,7 +1135,7 @@ describe('Variable Context', () => {
1158
1135
  name: 'Custom Network',
1159
1136
  version: '1.0.0',
1160
1137
  requires: {
1161
- machine: {
1138
+ system: {
1162
1139
  zone: 'dmz',
1163
1140
  },
1164
1141
  },
@@ -1198,7 +1175,7 @@ describe('Variable Context', () => {
1198
1175
  name: 'VPS External',
1199
1176
  version: '1.0.0',
1200
1177
  requires: {
1201
- machine: {
1178
+ system: {
1202
1179
  zone: 'external',
1203
1180
  },
1204
1181
  },
@@ -1228,7 +1205,7 @@ describe('Variable Context', () => {
1228
1205
  name: 'No Net Config',
1229
1206
  version: '1.0.0',
1230
1207
  requires: {
1231
- machine: {
1208
+ system: {
1232
1209
  zone: 'dmz',
1233
1210
  },
1234
1211
  },
@@ -14,7 +14,7 @@ import {
14
14
  systemSecrets,
15
15
  } from '../db/schema';
16
16
  import { allocateResources, getAllocation } from '../ipam/allocator';
17
- import { type ModuleManifest, getDeclaredSystems } from '../manifest/schema';
17
+ import { type ModuleManifest, getDeclaredSystems, getSingularSystemSpec } from '../manifest/schema';
18
18
  import { decryptSecret } from '../secrets/encryption';
19
19
  import { getOrCreateMasterKey } from '../secrets/master-key';
20
20
  import { upsertModuleConfig } from '../services/module-config';
@@ -76,7 +76,7 @@ async function autoAssignFromWellKnown(
76
76
  const wellKnown = getWellKnownCapability(capability.name);
77
77
 
78
78
  // Determine current zone (from config or manifest)
79
- const currentZone = selfConfig.zone || manifest.requires?.machine?.zone;
79
+ const currentZone = selfConfig.zone || getSingularSystemSpec(manifest)?.zone;
80
80
 
81
81
  // Auto-assign hostname if not already set
82
82
  if (!selfConfig.hostname) {
@@ -261,22 +261,22 @@ export async function buildResolutionContext(
261
261
  // Auto-apply VM resource defaults from manifest if not already configured
262
262
  if (module?.manifestData) {
263
263
  const manifest = module.manifestData as ModuleManifest;
264
- const machineResources = manifest.requires?.machine;
264
+ const systemResources = getSingularSystemSpec(manifest);
265
265
 
266
- if (machineResources) {
266
+ if (systemResources) {
267
267
  // Map manifest fields to module variable names and apply defaults
268
268
  const resourceMappings: Array<{
269
- manifestKey: keyof typeof machineResources;
269
+ manifestKey: keyof typeof systemResources;
270
270
  configKey: string;
271
271
  }> = [
272
- { manifestKey: 'cpu', configKey: 'cores' }, // manifest.requires.machine.cpu → cores variable
272
+ { manifestKey: 'cpu', configKey: 'cores' }, // manifest.requires.system.cpu → cores variable
273
273
  { manifestKey: 'memory', configKey: 'memory' },
274
274
  { manifestKey: 'disk', configKey: 'disk' },
275
275
  { manifestKey: 'storage', configKey: 'storage' },
276
276
  ];
277
277
 
278
278
  for (const { manifestKey, configKey } of resourceMappings) {
279
- const value = machineResources[manifestKey];
279
+ const value = systemResources[manifestKey];
280
280
 
281
281
  // Manifest fields are typed (cpu: number, storage: string, etc.).
282
282
  // Pass them through unstringified so valueJson preserves the
@@ -374,15 +374,13 @@ export async function buildResolutionContext(
374
374
  }
375
375
  }
376
376
 
377
- // Add machine requirements from manifest to selfConfig so templates can
378
- // reference them via $self:requires.machine.<field>
377
+ // Add system requirements from manifest to selfConfig so templates can
378
+ // reference them via $self:requires.system.<field>.
379
379
  if (module?.manifestData) {
380
- const manifest = module.manifestData as Record<string, unknown>;
381
- const requires = manifest.requires as Record<string, unknown> | undefined;
382
- if (requires?.machine) {
383
- const machineRequires = requires.machine as Record<string, unknown>;
384
- for (const [key, value] of Object.entries(machineRequires)) {
385
- selfConfig[`requires.machine.${key}`] = String(value);
380
+ const systemSpec = getSingularSystemSpec(module.manifestData as ModuleManifest);
381
+ if (systemSpec) {
382
+ for (const [key, value] of Object.entries(systemSpec)) {
383
+ selfConfig[`requires.system.${key}`] = String(value);
386
384
  }
387
385
  }
388
386
  }
@@ -550,7 +548,7 @@ export async function buildResolutionContext(
550
548
  // Auto-derive network config from zone (gateway, vlan, subnet, bridge)
551
549
  if (module?.manifestData) {
552
550
  const manifest = module.manifestData as ModuleManifest;
553
- const zone = selfConfig.zone || manifest.requires?.machine?.zone;
551
+ const zone = selfConfig.zone || getSingularSystemSpec(manifest)?.zone;
554
552
 
555
553
  // If zone from manifest but not in selfConfig, store it as first-class config
556
554
  if (zone && !selfConfig.zone) {
@@ -24,7 +24,7 @@ describe('lxc_nameserver composition', () => {
24
24
  id: 'consumer',
25
25
  name: 'consumer',
26
26
  version: '1.0.0',
27
- manifestData: { requires: { machine: { zone: 'app' } } },
27
+ manifestData: { requires: { system: { zone: 'app' } } },
28
28
  sourcePath: '/tmp/consumer',
29
29
  })
30
30
  .run();
package/tsconfig.json CHANGED
@@ -16,6 +16,6 @@
16
16
  "@/*": ["./src/*"]
17
17
  }
18
18
  },
19
- "include": ["src/**/*"],
19
+ "include": ["src/**/*", "test-integration/**/*"],
20
20
  "exclude": ["node_modules"]
21
21
  }