@celilo/cli 0.4.0 → 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/restore.ts +5 -0
- 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 +12 -7
- 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 +32 -2
- 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.test.ts +46 -0
- package/src/services/restore-from-file.ts +103 -5
- 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 {
|
|
@@ -98,6 +98,11 @@ export async function handleRestore(
|
|
|
98
98
|
if (result.systemDbApplied) lines.push(' • celilo.db swapped into place');
|
|
99
99
|
if (result.masterKeyApplied) lines.push(' • master.key swapped into place');
|
|
100
100
|
if (result.sshKeyApplied) lines.push(' • fleet SSH key restored to <data-dir>/.ssh/');
|
|
101
|
+
if (result.moduleSourcesApplied && result.moduleSourcesApplied > 0) {
|
|
102
|
+
lines.push(
|
|
103
|
+
` • module source laid down for ${result.moduleSourcesApplied} module(s) (paths reconciled to this box)`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
101
106
|
if (result.crossModuleApplied && result.crossModuleApplied.length > 0) {
|
|
102
107
|
lines.push(
|
|
103
108
|
` • terraform state restored for ${result.crossModuleApplied.length} module(s): ${result.crossModuleApplied.join(', ')}`,
|
|
@@ -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) {
|
|
@@ -402,11 +402,16 @@ async function installScriptDependencies(
|
|
|
402
402
|
targetPath: string,
|
|
403
403
|
manifest: ModuleManifest,
|
|
404
404
|
): Promise<void> {
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
405
|
+
// A module's scripts/ holds hook scripts AND/OR capability-provider
|
|
406
|
+
// implementations (e.g. dns_registrar's register-host.ts, referenced via
|
|
407
|
+
// provides.capabilities, not hooks). BOTH import @celilo/capabilities and
|
|
408
|
+
// need deps. A capability-only provider (hooks: {}) still has scripts to
|
|
409
|
+
// resolve — keying solely off hooks left its node_modules un-vendored, so
|
|
410
|
+
// the capability-loader's import() failed with ENOENT @celilo/capabilities.
|
|
411
|
+
const hasHooks = Boolean(manifest.hooks && Object.keys(manifest.hooks).length > 0);
|
|
412
|
+
const hasCapabilities = (manifest.provides?.capabilities?.length ?? 0) > 0;
|
|
413
|
+
if (!hasHooks && !hasCapabilities) {
|
|
414
|
+
return; // No hook or capability scripts → nothing to install
|
|
410
415
|
}
|
|
411
416
|
|
|
412
417
|
const scriptsDir = join(targetPath, 'scripts');
|
|
@@ -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
|
}
|
|
@@ -147,11 +147,41 @@ describe('celilo-mgmt on_backup', () => {
|
|
|
147
147
|
).toBe(true);
|
|
148
148
|
expect(existsSync(join(backupDir, 'cross_module_state', 'index.json'))).toBe(true);
|
|
149
149
|
|
|
150
|
-
expect(result.schema_version).toBe('1.
|
|
150
|
+
expect(result.schema_version).toBe('1.1');
|
|
151
151
|
expect(result.artifact_count).toBeGreaterThan(0);
|
|
152
152
|
expect(result.size_bytes).toBeGreaterThan(0);
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
+
it('captures LEAN module source (excludes generated/, node_modules/, large build artifacts)', async () => {
|
|
156
|
+
// stateDir = dirname(db_path) = dir; on_backup reads dir/modules/<id>.
|
|
157
|
+
const modSrc = join(dir, 'modules', 'caddy');
|
|
158
|
+
mkdirSync(join(modSrc, 'scripts', 'node_modules', '@celilo'), { recursive: true });
|
|
159
|
+
mkdirSync(join(modSrc, 'generated', 'terraform'), { recursive: true });
|
|
160
|
+
mkdirSync(join(modSrc, 'ansible', 'files'), { recursive: true });
|
|
161
|
+
writeFileSync(join(modSrc, 'manifest.yml'), 'id: caddy');
|
|
162
|
+
writeFileSync(join(modSrc, 'scripts', 'hook.ts'), '// hook');
|
|
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));
|
|
167
|
+
|
|
168
|
+
const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
|
|
169
|
+
await hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }));
|
|
170
|
+
|
|
171
|
+
// Source files captured...
|
|
172
|
+
expect(existsSync(join(backupDir, 'module_src', 'caddy', 'manifest.yml'))).toBe(true);
|
|
173
|
+
expect(existsSync(join(backupDir, 'module_src', 'caddy', 'scripts', 'hook.ts'))).toBe(true);
|
|
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).
|
|
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);
|
|
183
|
+
});
|
|
184
|
+
|
|
155
185
|
it('machine-pool.json is valid JSON (array)', async () => {
|
|
156
186
|
const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
|
|
157
187
|
await hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }));
|
|
@@ -168,7 +198,7 @@ describe('celilo-mgmt on_backup', () => {
|
|
|
168
198
|
|
|
169
199
|
expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
|
|
170
200
|
expect(existsSync(join(backupDir, 'cross_module_state'))).toBe(false);
|
|
171
|
-
expect(result.schema_version).toBe('1.
|
|
201
|
+
expect(result.schema_version).toBe('1.1');
|
|
172
202
|
});
|
|
173
203
|
|
|
174
204
|
it('proceeds (with a warning) when master.key is missing on disk', 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
|
})
|