@celilo/cli 1.11.0 → 1.13.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/CELILO_CORE_MODULES.md +2 -1
- package/CELILO_SUBSYSTEMS.md +17 -2
- package/package.json +3 -3
- package/src/cli/commands/alerts-list.ts +16 -1
- package/src/cli/commands/backup-list.test.ts +82 -1
- package/src/cli/commands/backup-list.ts +113 -4
- package/src/cli/commands/console.ts +122 -0
- package/src/cli/commands/module-list.ts +3 -41
- package/src/cli/commands/module-publish.ts +2 -0
- package/src/cli/completion.ts +5 -0
- package/src/cli/index.ts +25 -1
- package/src/console/closure.test.ts +246 -0
- package/src/console/closure.ts +208 -0
- package/src/console/control-plane-boundary.test.ts +75 -0
- package/src/console/projection.test.ts +231 -0
- package/src/console/projection.ts +327 -0
- package/src/db/schema.ts +19 -14
- package/src/hooks/broker.test.ts +4 -6
- package/src/hooks/executor.test.ts +85 -4
- package/src/hooks/executor.ts +164 -9
- package/src/hooks/hook-jail-unreachability.test.ts +173 -0
- package/src/hooks/hook-state-dir.test.ts +14 -2
- package/src/hooks/hook-timeout.test.ts +2 -4
- package/src/hooks/hook-trespass.test.ts +50 -5
- package/src/hooks/jail.test.ts +370 -0
- package/src/hooks/jail.ts +491 -0
- package/src/hooks/mount-set.ts +24 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
- package/src/manifest/icon-schema.test.ts +48 -0
- package/src/manifest/schema.ts +92 -0
- package/src/manifest/validate.test.ts +142 -0
- package/src/manifest/validate.ts +101 -0
- package/src/module/import.test.ts +116 -0
- package/src/module/import.ts +73 -1
- package/src/module/packaging/audit.ts +103 -1
- package/src/module/packaging/classify-module-path.test.ts +36 -0
- package/src/module/packaging/package-rules.ts +18 -0
- package/src/policy/capability-shape-baseline.ts +8 -0
- package/src/policy/module-business-baseline.ts +12 -0
- package/src/registry/client.ts +9 -0
- package/src/services/alerting/observed-health.ts +71 -0
- package/src/services/api-principal-enrolment.test.ts +179 -0
- package/src/services/api-principal-enrolment.ts +103 -0
- package/src/services/audit/backups.ts +10 -1
- package/src/services/backup-metadata.ts +19 -11
- package/src/services/consumer-cleanup.ts +31 -5
- package/src/services/instance-ops.test.ts +302 -0
- package/src/services/instance-ops.ts +292 -0
- package/src/services/module-instances.test.ts +428 -42
- package/src/services/module-instances.ts +219 -26
package/src/manifest/schema.ts
CHANGED
|
@@ -563,6 +563,14 @@ export type ModuleSubscription = z.infer<typeof ModuleSubscriptionSchema>;
|
|
|
563
563
|
* with via `celilo_contract`. The contract version determines the
|
|
564
564
|
* canonical inputs/outputs of every lifecycle hook (see `./contracts/v1.ts`).
|
|
565
565
|
*/
|
|
566
|
+
/**
|
|
567
|
+
* Codepoints carrying Unicode's `Emoji` property, which the `icon` field
|
|
568
|
+
* rejects. Note this property is broader than "looks like an emoji": ASCII
|
|
569
|
+
* digits, `#` and `*` carry it too, because they form keycap sequences. That
|
|
570
|
+
* over-rejection costs nothing — none of them is a plausible module glyph.
|
|
571
|
+
*/
|
|
572
|
+
const EMOJI_CODEPOINT = /\p{Emoji}/u;
|
|
573
|
+
|
|
566
574
|
export const ModuleManifestSchema = z
|
|
567
575
|
.object({
|
|
568
576
|
/**
|
|
@@ -584,6 +592,55 @@ export const ModuleManifestSchema = z
|
|
|
584
592
|
version: z.string().regex(/^\d+\.\d+\.\d+$/, 'Version must be semantic version (e.g., 1.0.0)'),
|
|
585
593
|
description: z.string().optional(),
|
|
586
594
|
|
|
595
|
+
/**
|
|
596
|
+
* One glyph identifying this module wherever celilo draws it — the console
|
|
597
|
+
* roster, the topology boxes, the registry browse page. Optional: a module
|
|
598
|
+
* declaring none falls back to the consumer's built-in table, then to a
|
|
599
|
+
* placeholder (openspec/changes/module-icons, D3/D5).
|
|
600
|
+
*
|
|
601
|
+
* Exactly one Unicode scalar, inside the BMP, without Unicode's `Emoji`
|
|
602
|
+
* property. These glyphs inherit the colour of the row they are drawn in,
|
|
603
|
+
* so a firing module's icon goes red with the rest of the row. An emoji
|
|
604
|
+
* codepoint paints its own colours and would stay cheerful while its
|
|
605
|
+
* module's state said otherwise.
|
|
606
|
+
*
|
|
607
|
+
* BMP-and-not-Emoji is a PROXY for "renders monochrome", not a proof. Some
|
|
608
|
+
* BMP codepoints outside the Emoji property still get an emoji font on some
|
|
609
|
+
* platforms. What it does catch is the whole SMP emoji range, which is
|
|
610
|
+
* where an author reaching for a padlock or a shield actually lands, and
|
|
611
|
+
* that is the case worth catching.
|
|
612
|
+
*
|
|
613
|
+
* `ModuleManifestSchema` is strict, so a celilo predating this field
|
|
614
|
+
* rejects a manifest declaring it. The CLI release accepting `icon` ships
|
|
615
|
+
* before any module published to the registry declares one (D2).
|
|
616
|
+
*/
|
|
617
|
+
icon: z
|
|
618
|
+
.string()
|
|
619
|
+
.superRefine((value, ctx) => {
|
|
620
|
+
const scalars = [...value];
|
|
621
|
+
if (scalars.length !== 1) {
|
|
622
|
+
ctx.addIssue({
|
|
623
|
+
code: z.ZodIssueCode.custom,
|
|
624
|
+
message: `icon must be exactly one character, got ${scalars.length}`,
|
|
625
|
+
});
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const codePoint = value.codePointAt(0) ?? 0;
|
|
629
|
+
if (codePoint > 0xffff || EMOJI_CODEPOINT.test(value)) {
|
|
630
|
+
const hex = codePoint.toString(16).toUpperCase().padStart(4, '0');
|
|
631
|
+
ctx.addIssue({
|
|
632
|
+
code: z.ZodIssueCode.custom,
|
|
633
|
+
message: [
|
|
634
|
+
`icon '${value}' (U+${hex}) must be a monochrome glyph: it inherits the colour`,
|
|
635
|
+
'of the row it is drawn in, and an emoji codepoint paints its own colours, so it',
|
|
636
|
+
"would stay cheerful while the module went red. Use a BMP symbol outside Unicode's",
|
|
637
|
+
"Emoji property (a key '\u26bf', not a padlock '\u{1f512}').",
|
|
638
|
+
].join(' '),
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
})
|
|
642
|
+
.optional(),
|
|
643
|
+
|
|
587
644
|
/**
|
|
588
645
|
* How `manifest.yml#version` (the PAYLOAD version) is determined — see
|
|
589
646
|
* openspec/changes/module-version-semantics/proposal.md / ISS-0151. The capability *contract* version
|
|
@@ -608,6 +665,41 @@ export const ModuleManifestSchema = z
|
|
|
608
665
|
.strict()
|
|
609
666
|
.optional(),
|
|
610
667
|
|
|
668
|
+
/**
|
|
669
|
+
* Submodules this module owns (openspec/changes/submodules, D1).
|
|
670
|
+
*
|
|
671
|
+
* Each name is a directory under this module's own `submodules/`, holding
|
|
672
|
+
* an ordinary module manifest. A submodule is never a top-level module: an
|
|
673
|
+
* operator does not import it, does not deploy it, and never sees it in the
|
|
674
|
+
* registry. It ships inside this package, versions with this manifest, and
|
|
675
|
+
* exists only as instances this module creates through the
|
|
676
|
+
* `celilo_module_deploy_worker` capability.
|
|
677
|
+
*
|
|
678
|
+
* Ownership is declared by the PARENT rather than flagged on the child, and
|
|
679
|
+
* that is what makes the exclusivity structural rather than enforced. A
|
|
680
|
+
* submodule is not in `modules/`, so there is nothing to deploy by accident
|
|
681
|
+
* and no flag for a code path to forget to check.
|
|
682
|
+
*
|
|
683
|
+
* A plain list of names, not a block. Per-submodule policy such as a
|
|
684
|
+
* maximum instance count is the operator's business rather than the
|
|
685
|
+
* author's — the same reason `requires.system` states a minimum rather than
|
|
686
|
+
* a deployed size. A `string | object` union stays reachable additively if
|
|
687
|
+
* a genuinely author-owned per-submodule fact ever turns up.
|
|
688
|
+
*
|
|
689
|
+
* v1.0 contract field — additive, no contract version bump needed, exactly
|
|
690
|
+
* like `optional.capabilities`.
|
|
691
|
+
*/
|
|
692
|
+
submodules: z
|
|
693
|
+
.array(
|
|
694
|
+
z
|
|
695
|
+
.string()
|
|
696
|
+
.regex(
|
|
697
|
+
/^[a-z0-9]+(-[a-z0-9]+)*$/,
|
|
698
|
+
'Submodule name must use kebab-case — it names a directory under submodules/',
|
|
699
|
+
),
|
|
700
|
+
)
|
|
701
|
+
.optional(),
|
|
702
|
+
|
|
611
703
|
requires: z
|
|
612
704
|
.object({
|
|
613
705
|
capabilities: z.array(CapabilityRequirementSchema).default([]),
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
validateHookContract,
|
|
8
8
|
validateManifest,
|
|
9
9
|
validateProvidesNoCrossCapabilityRefs,
|
|
10
|
+
validateSubmoduleDeclaration,
|
|
11
|
+
validateSubmoduleManifest,
|
|
10
12
|
validateVariableSources,
|
|
11
13
|
} from './validate';
|
|
12
14
|
|
|
@@ -1531,3 +1533,143 @@ base_module_aspect:
|
|
|
1531
1533
|
expect(result.success).toBe(false);
|
|
1532
1534
|
});
|
|
1533
1535
|
});
|
|
1536
|
+
|
|
1537
|
+
describe('submodules (openspec/changes/submodules D1, D9)', () => {
|
|
1538
|
+
const parent = (over: Record<string, unknown> = {}) => ({
|
|
1539
|
+
celilo_contract: '1.0' as const,
|
|
1540
|
+
id: 'forgejo',
|
|
1541
|
+
name: 'Forgejo',
|
|
1542
|
+
version: '1.0.0',
|
|
1543
|
+
requires: { capabilities: [] },
|
|
1544
|
+
provides: { capabilities: [] },
|
|
1545
|
+
variables: { owns: [], imports: [] },
|
|
1546
|
+
...over,
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
const submodule = (over: Record<string, unknown> = {}) => ({
|
|
1550
|
+
celilo_contract: '1.0' as const,
|
|
1551
|
+
id: 'runner',
|
|
1552
|
+
name: 'Forgejo Runner',
|
|
1553
|
+
version: '1.0.0',
|
|
1554
|
+
requires: { capabilities: [] },
|
|
1555
|
+
provides: { capabilities: [] },
|
|
1556
|
+
variables: { owns: [], imports: [] },
|
|
1557
|
+
...over,
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
describe('validateSubmoduleDeclaration', () => {
|
|
1561
|
+
test('a module declaring no submodules is unaffected', () => {
|
|
1562
|
+
expect(validateSubmoduleDeclaration(parent())).toBeNull();
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
test('accepts a plain list of names', () => {
|
|
1566
|
+
expect(validateSubmoduleDeclaration(parent({ submodules: ['runner'] }))).toBeNull();
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
test('rejects a duplicate name', () => {
|
|
1570
|
+
const result = validateSubmoduleDeclaration(parent({ submodules: ['runner', 'runner'] }));
|
|
1571
|
+
expect(result).not.toBeNull();
|
|
1572
|
+
expect(result?.errors[0]?.message).toContain('more than once');
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1575
|
+
test('rejects a submodule sharing its parent id', () => {
|
|
1576
|
+
const result = validateSubmoduleDeclaration(parent({ submodules: ['forgejo'] }));
|
|
1577
|
+
expect(result).not.toBeNull();
|
|
1578
|
+
expect(result?.errors[0]?.message).toContain("parent module's id");
|
|
1579
|
+
});
|
|
1580
|
+
});
|
|
1581
|
+
|
|
1582
|
+
describe('validateSubmoduleManifest', () => {
|
|
1583
|
+
test('accepts an ordinary submodule manifest', () => {
|
|
1584
|
+
expect(validateSubmoduleManifest(parent(), 'runner', submodule())).toBeNull();
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
test('rejects an id that disagrees with its directory', () => {
|
|
1588
|
+
const result = validateSubmoduleManifest(parent(), 'runner', submodule({ id: 'builder' }));
|
|
1589
|
+
expect(result).not.toBeNull();
|
|
1590
|
+
expect(result?.errors[0]?.message).toContain('does not match its directory');
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
// D9: a capability resolves to ONE provider, and celilo's two bespoke
|
|
1594
|
+
// multi-provider rules RESOLVE rather than fail, so N instances would bind
|
|
1595
|
+
// a consumer to whichever was created first — silently.
|
|
1596
|
+
test('rejects a submodule that provides a capability', () => {
|
|
1597
|
+
const result = validateSubmoduleManifest(
|
|
1598
|
+
parent(),
|
|
1599
|
+
'runner',
|
|
1600
|
+
submodule({
|
|
1601
|
+
provides: { capabilities: [{ name: 'source_forge', version: '1.0.0', data: {} }] },
|
|
1602
|
+
}),
|
|
1603
|
+
);
|
|
1604
|
+
expect(result).not.toBeNull();
|
|
1605
|
+
expect(result?.errors[0]?.message).toContain('may not provide a capability');
|
|
1606
|
+
expect(result?.errors[0]?.message).toContain('source_forge');
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
test('a submodule may still REQUIRE capabilities', () => {
|
|
1610
|
+
const result = validateSubmoduleManifest(
|
|
1611
|
+
parent(),
|
|
1612
|
+
'runner',
|
|
1613
|
+
submodule({ requires: { capabilities: [{ name: 'source_forge', version: '1.0.0' }] } }),
|
|
1614
|
+
);
|
|
1615
|
+
expect(result).toBeNull();
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
test('rejects nesting — ownership is one level deep', () => {
|
|
1619
|
+
const result = validateSubmoduleManifest(
|
|
1620
|
+
parent(),
|
|
1621
|
+
'runner',
|
|
1622
|
+
submodule({ submodules: ['nested'] }),
|
|
1623
|
+
);
|
|
1624
|
+
expect(result).not.toBeNull();
|
|
1625
|
+
expect(result?.errors[0]?.message).toContain('may not declare submodules of its own');
|
|
1626
|
+
});
|
|
1627
|
+
|
|
1628
|
+
test('reports every problem at once rather than stopping at the first', () => {
|
|
1629
|
+
const result = validateSubmoduleManifest(
|
|
1630
|
+
parent(),
|
|
1631
|
+
'runner',
|
|
1632
|
+
submodule({
|
|
1633
|
+
id: 'builder',
|
|
1634
|
+
provides: { capabilities: [{ name: 'source_forge', version: '1.0.0', data: {} }] },
|
|
1635
|
+
submodules: ['nested'],
|
|
1636
|
+
}),
|
|
1637
|
+
);
|
|
1638
|
+
expect(result?.errors).toHaveLength(3);
|
|
1639
|
+
});
|
|
1640
|
+
});
|
|
1641
|
+
|
|
1642
|
+
describe('schema', () => {
|
|
1643
|
+
test('parses a manifest declaring submodules', () => {
|
|
1644
|
+
const yaml = `
|
|
1645
|
+
celilo_contract: "1.0"
|
|
1646
|
+
id: forgejo
|
|
1647
|
+
name: Forgejo
|
|
1648
|
+
version: 1.0.0
|
|
1649
|
+
submodules:
|
|
1650
|
+
- runner
|
|
1651
|
+
requires:
|
|
1652
|
+
capabilities: []
|
|
1653
|
+
`;
|
|
1654
|
+
const result = validateManifest(yaml);
|
|
1655
|
+
expect(result.success).toBe(true);
|
|
1656
|
+
if (result.success) expect(result.data.submodules).toEqual(['runner']);
|
|
1657
|
+
});
|
|
1658
|
+
|
|
1659
|
+
test('rejects a non-kebab-case submodule name', () => {
|
|
1660
|
+
const yaml = `
|
|
1661
|
+
celilo_contract: "1.0"
|
|
1662
|
+
id: forgejo
|
|
1663
|
+
name: Forgejo
|
|
1664
|
+
version: 1.0.0
|
|
1665
|
+
submodules:
|
|
1666
|
+
- Runner_One
|
|
1667
|
+
requires:
|
|
1668
|
+
capabilities: []
|
|
1669
|
+
`;
|
|
1670
|
+
const result = validateManifest(yaml);
|
|
1671
|
+
expect(result.success).toBe(false);
|
|
1672
|
+
if (!result.success) expect(result.errors[0]?.message).toContain('kebab-case');
|
|
1673
|
+
});
|
|
1674
|
+
});
|
|
1675
|
+
});
|
package/src/manifest/validate.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { KNOWN_CAPABILITY_NAMES, isProviderView } from '@celilo/capabilities';
|
|
2
2
|
import { parse as parseYaml } from 'yaml';
|
|
3
3
|
import type { ZodError } from 'zod';
|
|
4
|
+
import { SUBMODULES_DIR } from '../module/packaging/package-rules';
|
|
4
5
|
import { validateModuleZoneRequirements } from '../services/zone-policy';
|
|
5
6
|
import { resolveContract, supportedContractVersions } from './contracts';
|
|
6
7
|
import { ModuleManifestSchema, getSingularSystemSpec } from './schema';
|
|
@@ -495,3 +496,103 @@ export function validateProvidesNoCrossCapabilityRefs(
|
|
|
495
496
|
|
|
496
497
|
return null;
|
|
497
498
|
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The directory a parent's submodules live in, relative to the parent's own
|
|
502
|
+
* source root. Re-exported rather than redefined: the packaging rules need the
|
|
503
|
+
* same name and `package-rules.ts` is deliberately dependency-free (the
|
|
504
|
+
* registry server imports it), so it owns the definition and everything
|
|
505
|
+
* manifest-facing reads it from here.
|
|
506
|
+
*/
|
|
507
|
+
export { SUBMODULES_DIR };
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Validate a PARENT's `submodules` declaration.
|
|
511
|
+
*
|
|
512
|
+
* Policy function - checks the declaration itself, not the submodules it names.
|
|
513
|
+
* Reading those is I/O and belongs to the caller.
|
|
514
|
+
*
|
|
515
|
+
* @param manifest - Validated manifest of the parent
|
|
516
|
+
* @returns Validation errors if any, null if clean
|
|
517
|
+
*/
|
|
518
|
+
export function validateSubmoduleDeclaration(manifest: ModuleManifest): ValidationError | null {
|
|
519
|
+
const declared = manifest.submodules ?? [];
|
|
520
|
+
if (declared.length === 0) return null;
|
|
521
|
+
|
|
522
|
+
const errors: Array<{ path: string; message: string }> = [];
|
|
523
|
+
const seen = new Set<string>();
|
|
524
|
+
|
|
525
|
+
for (const [index, name] of declared.entries()) {
|
|
526
|
+
if (seen.has(name)) {
|
|
527
|
+
errors.push({
|
|
528
|
+
path: `submodules.${index}`,
|
|
529
|
+
message: `Submodule '${name}' is declared more than once`,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
seen.add(name);
|
|
533
|
+
|
|
534
|
+
// The derived id of an instance is built from the parent and the submodule
|
|
535
|
+
// name, so a submodule sharing its parent's id yields an instance id that
|
|
536
|
+
// reads as the parent's own. Cheap to refuse, confusing to debug.
|
|
537
|
+
if (name === manifest.id) {
|
|
538
|
+
errors.push({
|
|
539
|
+
path: `submodules.${index}`,
|
|
540
|
+
message: `Submodule '${name}' cannot share its parent module's id`,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return errors.length > 0 ? { success: false, errors } : null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Validate that a SUBMODULE's own manifest is legal as a submodule.
|
|
550
|
+
*
|
|
551
|
+
* Policy function - the caller has already read and schema-validated the
|
|
552
|
+
* submodule's manifest; this decides whether it may be one.
|
|
553
|
+
*
|
|
554
|
+
* @param parent - The declaring parent's manifest
|
|
555
|
+
* @param submoduleName - The name the parent declared, i.e. its directory
|
|
556
|
+
* @param submodule - The submodule's own validated manifest
|
|
557
|
+
* @returns Validation errors if any, null if clean
|
|
558
|
+
*/
|
|
559
|
+
export function validateSubmoduleManifest(
|
|
560
|
+
parent: ModuleManifest,
|
|
561
|
+
submoduleName: string,
|
|
562
|
+
submodule: ModuleManifest,
|
|
563
|
+
): ValidationError | null {
|
|
564
|
+
const errors: Array<{ path: string; message: string }> = [];
|
|
565
|
+
const where = `${SUBMODULES_DIR}/${submoduleName}/manifest.yml`;
|
|
566
|
+
|
|
567
|
+
if (submodule.id !== submoduleName) {
|
|
568
|
+
errors.push({
|
|
569
|
+
path: `${where}#id`,
|
|
570
|
+
message: `Submodule id '${submodule.id}' does not match its directory '${submoduleName}'. A submodule is addressed by the name its parent declares, so the two must agree.`,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// D9: celilo has no general rule for resolving a capability name to one of
|
|
575
|
+
// several providers. The two bespoke rules that exist RESOLVE rather than
|
|
576
|
+
// fail — `firewall` builds a chain, `dns_registrar` takes the first row — so
|
|
577
|
+
// N instances registering one capability would silently bind a consumer to
|
|
578
|
+
// whichever was created first. Refused here rather than discovered there.
|
|
579
|
+
if ((submodule.provides?.capabilities ?? []).length > 0) {
|
|
580
|
+
const names = submodule.provides.capabilities.map((c) => c.name).join(', ');
|
|
581
|
+
errors.push({
|
|
582
|
+
path: `${where}#provides.capabilities`,
|
|
583
|
+
message: `A submodule may not provide a capability (declares: ${names}). A capability resolves to ONE provider, and a submodule exists as many instances, so a consumer would bind to whichever instance happened to be created first. Move the capability to '${parent.id}'.`,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// One level only. D4's instance layout puts every instance flat under
|
|
588
|
+
// `modules/`, and nesting would make a derived id ambiguous about which
|
|
589
|
+
// ancestor owns it.
|
|
590
|
+
if ((submodule.submodules ?? []).length > 0) {
|
|
591
|
+
errors.push({
|
|
592
|
+
path: `${where}#submodules`,
|
|
593
|
+
message: `A submodule may not declare submodules of its own. Ownership is one level deep: '${parent.id}' owns '${submoduleName}', and nothing owns anything below that.`,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return errors.length > 0 ? { success: false, errors } : null;
|
|
598
|
+
}
|
|
@@ -171,6 +171,122 @@ version: 1.0.0
|
|
|
171
171
|
});
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
+
describe('submodules (openspec/changes/submodules D1)', () => {
|
|
175
|
+
const PARENT = `
|
|
176
|
+
celilo_contract: "1.0"
|
|
177
|
+
id: forgejo
|
|
178
|
+
name: Forgejo
|
|
179
|
+
version: 1.0.0
|
|
180
|
+
submodules:
|
|
181
|
+
- runner
|
|
182
|
+
`;
|
|
183
|
+
const SUBMODULE = `
|
|
184
|
+
celilo_contract: "1.0"
|
|
185
|
+
id: runner
|
|
186
|
+
name: Forgejo Runner
|
|
187
|
+
version: 1.0.0
|
|
188
|
+
`;
|
|
189
|
+
|
|
190
|
+
async function writeParent(dirName: string, parent: string, submodule?: string) {
|
|
191
|
+
const moduleDir = join(TEST_FIXTURES_DIR, dirName);
|
|
192
|
+
await mkdir(moduleDir, { recursive: true });
|
|
193
|
+
await writeFile(join(moduleDir, 'manifest.yml'), parent);
|
|
194
|
+
if (submodule !== undefined) {
|
|
195
|
+
const subDir = join(moduleDir, 'submodules', 'runner');
|
|
196
|
+
await mkdir(subDir, { recursive: true });
|
|
197
|
+
await writeFile(join(subDir, 'manifest.yml'), submodule);
|
|
198
|
+
}
|
|
199
|
+
return moduleDir;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
test('accepts a parent whose declared submodule is present and legal', async () => {
|
|
203
|
+
const dir = await writeParent('forgejo-ok', PARENT, SUBMODULE);
|
|
204
|
+
const result = await readModuleManifest(dir);
|
|
205
|
+
expect(result.success).toBe(true);
|
|
206
|
+
if (result.success) expect(result.manifest.submodules).toEqual(['runner']);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// The point of validating at PARENT import: an instantiation happens with
|
|
210
|
+
// no operator present, which is the worst moment to learn a manifest is
|
|
211
|
+
// broken.
|
|
212
|
+
test('rejects a parent declaring a submodule that does not exist', async () => {
|
|
213
|
+
const dir = await writeParent('forgejo-missing', PARENT);
|
|
214
|
+
const result = await readModuleManifest(dir);
|
|
215
|
+
expect(result.success).toBe(false);
|
|
216
|
+
if (!result.success) {
|
|
217
|
+
expect(result.error).toContain('submodules/runner/manifest.yml does not exist');
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('rejects a parent whose submodule provides a capability', async () => {
|
|
222
|
+
const dir = await writeParent(
|
|
223
|
+
'forgejo-provides',
|
|
224
|
+
PARENT,
|
|
225
|
+
`${SUBMODULE}
|
|
226
|
+
provides:
|
|
227
|
+
capabilities:
|
|
228
|
+
- name: source_forge
|
|
229
|
+
version: 1.0.0
|
|
230
|
+
data: {}
|
|
231
|
+
`,
|
|
232
|
+
);
|
|
233
|
+
const result = await readModuleManifest(dir);
|
|
234
|
+
expect(result.success).toBe(false);
|
|
235
|
+
if (!result.success) expect(result.error).toContain('may not provide a capability');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('rejects a submodule whose id disagrees with its directory', async () => {
|
|
239
|
+
const dir = await writeParent(
|
|
240
|
+
'forgejo-mismatch',
|
|
241
|
+
PARENT,
|
|
242
|
+
SUBMODULE.replace('id: runner', 'id: builder'),
|
|
243
|
+
);
|
|
244
|
+
const result = await readModuleManifest(dir);
|
|
245
|
+
expect(result.success).toBe(false);
|
|
246
|
+
if (!result.success) expect(result.error).toContain('does not match its directory');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('a module declaring no submodules is unaffected', async () => {
|
|
250
|
+
const dir = await writeParent(
|
|
251
|
+
'plain-module',
|
|
252
|
+
`
|
|
253
|
+
celilo_contract: "1.0"
|
|
254
|
+
id: plain
|
|
255
|
+
name: Plain
|
|
256
|
+
version: 1.0.0
|
|
257
|
+
`,
|
|
258
|
+
);
|
|
259
|
+
const result = await readModuleManifest(dir);
|
|
260
|
+
expect(result.success).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// A submodule reaches the fleet inside its parent's package. The registry
|
|
264
|
+
// path is already closed (submodules are never published separately), so a
|
|
265
|
+
// local path is the only open door and this is what shuts it.
|
|
266
|
+
test('refuses to import a submodule directory on its own, naming the parent', async () => {
|
|
267
|
+
await writeParent('forgejo-direct', PARENT, SUBMODULE);
|
|
268
|
+
const submodulePath = join(TEST_FIXTURES_DIR, 'forgejo-direct', 'submodules', 'runner');
|
|
269
|
+
|
|
270
|
+
const error = validateModuleDirectory(submodulePath);
|
|
271
|
+
|
|
272
|
+
expect(error).not.toBeNull();
|
|
273
|
+
expect(error).toContain("is a submodule of 'forgejo-direct'");
|
|
274
|
+
expect(error).toContain('Import');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('a trailing slash does not defeat the submodule refusal', async () => {
|
|
278
|
+
await writeParent('forgejo-slash', PARENT, SUBMODULE);
|
|
279
|
+
const submodulePath = `${join(TEST_FIXTURES_DIR, 'forgejo-slash', 'submodules', 'runner')}/`;
|
|
280
|
+
|
|
281
|
+
expect(validateModuleDirectory(submodulePath)).toContain('is a submodule of');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('an ordinary module directory is still importable', async () => {
|
|
285
|
+
const dir = await writeParent('ordinary', PARENT, SUBMODULE);
|
|
286
|
+
expect(validateModuleDirectory(dir)).toBeNull();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
174
290
|
describe('copyModuleFiles', () => {
|
|
175
291
|
test('should copy all files from source to target', async () => {
|
|
176
292
|
const sourceDir = join(TEST_FIXTURES_DIR, 'source');
|
package/src/module/import.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
2
|
import { existsSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { copyFile, mkdir, readFile, readdir } from 'node:fs/promises';
|
|
4
|
-
import { join, relative } from 'node:path';
|
|
4
|
+
import { basename, dirname, join, relative } from 'node:path';
|
|
5
5
|
import { eq } from 'drizzle-orm';
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { getWellKnownCapability, isWellKnown } from '../capabilities/well-known';
|
|
@@ -12,12 +12,15 @@ import { capabilities, moduleIntegrity, modules } from '../db/schema';
|
|
|
12
12
|
import type { NewModule, NewModuleIntegrity } from '../db/schema';
|
|
13
13
|
import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
|
|
14
14
|
import {
|
|
15
|
+
SUBMODULES_DIR,
|
|
15
16
|
validateCapabilityNames,
|
|
16
17
|
validateDeriveFromSources,
|
|
17
18
|
validateHookContract,
|
|
18
19
|
validateManifest,
|
|
19
20
|
validatePrivilegedCapabilities,
|
|
20
21
|
validateProvidesNoCrossCapabilityRefs,
|
|
22
|
+
validateSubmoduleDeclaration,
|
|
23
|
+
validateSubmoduleManifest,
|
|
21
24
|
validateVariableSources,
|
|
22
25
|
validateZoneRequirements,
|
|
23
26
|
} from '../manifest/validate';
|
|
@@ -93,6 +96,17 @@ export function validateModuleDirectory(sourcePath: string): string | null {
|
|
|
93
96
|
return `Module directory does not exist: ${sourcePath}`;
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
// A submodule is not a module (design D1). It reaches the fleet inside its
|
|
100
|
+
// parent's package and exists only as instances the parent creates, so
|
|
101
|
+
// importing one on its own would produce a top-level module that was never
|
|
102
|
+
// written to be singular. The registry path is already closed — submodules
|
|
103
|
+
// are never published separately — so a local path is the only open door.
|
|
104
|
+
const parentDir = dirname(sourcePath.replace(/\/+$/, ''));
|
|
105
|
+
if (basename(parentDir) === SUBMODULES_DIR) {
|
|
106
|
+
const owner = basename(dirname(parentDir));
|
|
107
|
+
return `'${basename(sourcePath)}' is a submodule of '${owner}', not a module. Import '${owner}' instead — its package carries this submodule, and only '${owner}' can instantiate it.`;
|
|
108
|
+
}
|
|
109
|
+
|
|
96
110
|
// Check it's a directory
|
|
97
111
|
const stats = statSync(sourcePath);
|
|
98
112
|
if (!stats.isDirectory()) {
|
|
@@ -208,12 +222,70 @@ export async function readModuleManifest(
|
|
|
208
222
|
};
|
|
209
223
|
}
|
|
210
224
|
|
|
225
|
+
const submoduleCheck = await validateDeclaredSubmodules(sourcePath, validationResult.data);
|
|
226
|
+
if (submoduleCheck) {
|
|
227
|
+
return { success: false, error: submoduleCheck };
|
|
228
|
+
}
|
|
229
|
+
|
|
211
230
|
return {
|
|
212
231
|
success: true,
|
|
213
232
|
manifest: validationResult.data,
|
|
214
233
|
};
|
|
215
234
|
}
|
|
216
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Read and validate every submodule a parent declares.
|
|
238
|
+
*
|
|
239
|
+
* Runs at PARENT IMPORT so a broken submodule fails the parent, rather than
|
|
240
|
+
* surfacing at the first instantiation — which happens with no operator
|
|
241
|
+
* present and is the worst possible moment to learn a manifest is wrong
|
|
242
|
+
* (design D1).
|
|
243
|
+
*
|
|
244
|
+
* @param sourcePath - The parent's module directory
|
|
245
|
+
* @param parent - The parent's validated manifest
|
|
246
|
+
* @returns An error message if any submodule is missing or illegal, null if clean
|
|
247
|
+
*/
|
|
248
|
+
async function validateDeclaredSubmodules(
|
|
249
|
+
sourcePath: string,
|
|
250
|
+
parent: ModuleManifest,
|
|
251
|
+
): Promise<string | null> {
|
|
252
|
+
const declarationCheck = validateSubmoduleDeclaration(parent);
|
|
253
|
+
if (declarationCheck) {
|
|
254
|
+
const errorMessages = declarationCheck.errors.map((e) => `${e.path}: ${e.message}`).join('\n');
|
|
255
|
+
return `Submodule declaration validation failed:\n${errorMessages}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const name of parent.submodules ?? []) {
|
|
259
|
+
const submodulePath = join(sourcePath, SUBMODULES_DIR, name);
|
|
260
|
+
const manifestPath = join(submodulePath, 'manifest.yml');
|
|
261
|
+
|
|
262
|
+
if (!existsSync(manifestPath)) {
|
|
263
|
+
return `Module '${parent.id}' declares submodule '${name}', but ${SUBMODULES_DIR}/${name}/manifest.yml does not exist.`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let yamlContent: string;
|
|
267
|
+
try {
|
|
268
|
+
yamlContent = await readFile(manifestPath, 'utf-8');
|
|
269
|
+
} catch (error) {
|
|
270
|
+
return `Failed to read ${SUBMODULES_DIR}/${name}/manifest.yml: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const parsed = validateManifest(yamlContent);
|
|
274
|
+
if (!parsed.success) {
|
|
275
|
+
const errorMessages = parsed.errors.map((e) => `${e.path}: ${e.message}`).join(', ');
|
|
276
|
+
return `Submodule '${name}' has an invalid manifest: ${errorMessages}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const submoduleCheck = validateSubmoduleManifest(parent, name, parsed.data);
|
|
280
|
+
if (submoduleCheck) {
|
|
281
|
+
const errorMessages = submoduleCheck.errors.map((e) => `${e.path}: ${e.message}`).join('\n');
|
|
282
|
+
return `Submodule '${name}' validation failed:\n${errorMessages}`;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
217
289
|
/**
|
|
218
290
|
* Copy module files to target directory
|
|
219
291
|
*
|