@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 +1 -1
- package/src/cli/commands/module-show.ts +3 -3
- package/src/cli/commands/status.ts +2 -2
- package/src/hooks/capability-loader.ts +16 -5
- package/src/manifest/schema.ts +26 -15
- package/src/manifest/template-validator.test.ts +8 -8
- package/src/manifest/template-validator.ts +3 -3
- package/src/manifest/validate.test.ts +18 -17
- package/src/manifest/validate.ts +6 -6
- package/src/module/import.ts +2 -2
- package/src/services/celilo-events.test.ts +35 -5
- package/src/services/celilo-events.ts +17 -3
- package/src/services/celilo-mgmt-hooks.test.ts +14 -3
- package/src/services/deploy-preflight.ts +4 -3
- package/src/services/deployed-systems.test.ts +2 -2
- package/src/services/deployed-systems.ts +1 -1
- package/src/services/infrastructure-selector.test.ts +14 -14
- package/src/services/infrastructure-selector.ts +11 -12
- package/src/services/module-deploy.ts +4 -4
- package/src/services/restore-from-file.ts +13 -2
- package/src/services/terraform-safety.ts +8 -2
- package/src/services/zone-policy.ts +2 -2
- package/src/templates/generator.test.ts +13 -7
- package/src/templates/generator.ts +20 -6
- package/src/variables/context.test.ts +19 -42
- package/src/variables/context.ts +14 -16
- package/src/variables/lxc-nameserver.test.ts +1 -1
- package/tsconfig.json +1 -1
package/package.json
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
373
|
-
`public_web reconcile for ${consumingModuleId} did not finish within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} change event(s))
|
|
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
|
-
}
|
|
376
|
-
|
|
377
|
-
|
|
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
|
},
|
package/src/manifest/schema.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
*
|
|
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
|
-
|
|
751
|
-
|
|
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
|
-
|
|
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.
|
|
57
|
+
test('validates nested manifest paths (requires.system.*)', async () => {
|
|
58
58
|
const manifest = createTestManifest();
|
|
59
59
|
|
|
60
|
-
// Test that requires.
|
|
61
|
-
const
|
|
60
|
+
// Test that requires.system.cpu is valid
|
|
61
|
+
const system = manifest.requires.system;
|
|
62
62
|
|
|
63
|
-
expect(
|
|
64
|
-
expect(
|
|
65
|
-
expect(
|
|
66
|
-
expect(
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
75
|
-
expect(result.data.requires.
|
|
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
|
|
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
|
-
|
|
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.
|
|
467
|
-
expect(result.data.requires.
|
|
468
|
-
expect(result.data.requires.
|
|
469
|
-
|
|
470
|
-
expect(result.data
|
|
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
|
-
|
|
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.
|
|
492
|
-
expect(result.data.requires.
|
|
493
|
-
expect(result.data.requires.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
542
|
+
system:
|
|
542
543
|
cpu: 2.5
|
|
543
544
|
zone: app
|
|
544
545
|
`;
|
package/src/manifest/validate.ts
CHANGED
|
@@ -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.
|
|
308
|
-
const hasInfrastructureSpec = manifest
|
|
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 =
|
|
312
|
+
const zone = hasInfrastructureSpec.zone;
|
|
313
313
|
|
|
314
314
|
if (!zone) {
|
|
315
315
|
errors.push({
|
|
316
|
-
path: 'requires.
|
|
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.
|
|
330
|
+
path: 'requires.system.zone',
|
|
331
331
|
message: zoneValidation.error,
|
|
332
332
|
});
|
|
333
333
|
}
|
package/src/module/import.ts
CHANGED
|
@@ -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
|
|
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
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
245
|
-
|
|
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: {
|
|
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: {
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
227
|
+
await expect(selectInfrastructure(module)).rejects.toThrow(/missing requires.system.zone/);
|
|
228
228
|
});
|
|
229
229
|
|
|
230
|
-
it('supports requires.
|
|
230
|
+
it('supports requires.system format', async () => {
|
|
231
231
|
const db = createDbClient({ path: testDbPath });
|
|
232
232
|
|
|
233
|
-
// Create test module with requires.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
29
|
-
const machine = manifest.requires?.machine;
|
|
28
|
+
const system = getSingularSystemSpec(manifest);
|
|
30
29
|
|
|
31
|
-
if (!
|
|
30
|
+
if (!system) {
|
|
32
31
|
throw new InfrastructureError(
|
|
33
|
-
`Module ${module.id} manifest missing requires.
|
|
32
|
+
`Module ${module.id} manifest missing requires.system configuration`,
|
|
34
33
|
);
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
if (!
|
|
36
|
+
if (!system.zone) {
|
|
38
37
|
throw new InfrastructureError(
|
|
39
|
-
`Module ${module.id} manifest missing requires.
|
|
38
|
+
`Module ${module.id} manifest missing requires.system.zone field`,
|
|
40
39
|
);
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
return {
|
|
44
|
-
cpu:
|
|
45
|
-
memory:
|
|
46
|
-
disk:
|
|
47
|
-
storage:
|
|
48
|
-
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
61
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
376
|
-
expect(context.selfConfig['requires.
|
|
377
|
-
expect(context.selfConfig['requires.
|
|
378
|
-
expect(context.selfConfig['requires.
|
|
379
|
-
expect(context.selfConfig['requires.
|
|
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
|
-
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1208
|
+
system: {
|
|
1232
1209
|
zone: 'dmz',
|
|
1233
1210
|
},
|
|
1234
1211
|
},
|
package/src/variables/context.ts
CHANGED
|
@@ -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
|
|
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
|
|
264
|
+
const systemResources = getSingularSystemSpec(manifest);
|
|
265
265
|
|
|
266
|
-
if (
|
|
266
|
+
if (systemResources) {
|
|
267
267
|
// Map manifest fields to module variable names and apply defaults
|
|
268
268
|
const resourceMappings: Array<{
|
|
269
|
-
manifestKey: keyof typeof
|
|
269
|
+
manifestKey: keyof typeof systemResources;
|
|
270
270
|
configKey: string;
|
|
271
271
|
}> = [
|
|
272
|
-
{ manifestKey: 'cpu', configKey: 'cores' }, // manifest.requires.
|
|
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 =
|
|
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
|
|
378
|
-
// reference them via $self:requires.
|
|
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
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
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: {
|
|
27
|
+
manifestData: { requires: { system: { zone: 'app' } } },
|
|
28
28
|
sourcePath: '/tmp/consumer',
|
|
29
29
|
})
|
|
30
30
|
.run();
|