@celilo/cli 1.5.0 → 1.7.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 +18 -2
- package/MODULE_PRIMITIVES.md +25 -7
- package/drizzle/0026_module_integrity_version.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/capabilities/lookup.ts +39 -29
- package/src/capabilities/secret-ref.test.ts +24 -0
- package/src/capabilities/secret-validation.ts +50 -0
- package/src/capabilities/validation.test.ts +187 -2
- package/src/capabilities/validation.ts +53 -1
- package/src/cli/commands/alerts-sweep.ts +18 -0
- package/src/cli/commands/module-audit.ts +5 -2
- package/src/cli/commands/module-remove.ts +34 -2
- package/src/cli/commands/module-update.test.ts +238 -3
- package/src/cli/commands/module-update.ts +206 -12
- package/src/cli/commands/module-verify.ts +77 -13
- package/src/cli/commands/service-set-credentials.test.ts +108 -0
- package/src/cli/commands/service-set-credentials.ts +115 -0
- package/src/cli/commands/system-audit.ts +17 -0
- package/src/cli/commands/system-doctor.ts +78 -2
- package/src/cli/commands/system-migrate.ts +6 -4
- package/src/cli/commands/system-update.ts +33 -3
- package/src/cli/completion.ts +16 -1
- package/src/cli/index.ts +11 -2
- package/src/cli/tui/audit-state.ts +11 -3
- package/src/cli/tui/audit-tui.tsx +10 -4
- package/src/cli/tui/icons.ts +9 -2
- package/src/cli/tui/modals/analyzing.tsx +3 -0
- package/src/db/client.ts +10 -8
- package/src/db/migrate.test.ts +147 -0
- package/src/db/migrate.ts +69 -1
- package/src/db/schema.ts +5 -0
- package/src/hooks/capability-loader.test.ts +55 -0
- package/src/hooks/capability-loader.ts +16 -1
- package/src/manifest/json-schema-roundtrip.test.ts +12 -4
- package/src/manifest/schema.ts +23 -0
- package/src/module/import.ts +56 -40
- package/src/module/packaging/audit.ts +103 -28
- package/src/module/packaging/build.ts +12 -53
- package/src/module/packaging/classify-module-path.test.ts +104 -0
- package/src/module/packaging/extract.ts +31 -3
- package/src/module/packaging/generated-plane.test.ts +79 -0
- package/src/module/packaging/generated-plane.ts +134 -0
- package/src/module/packaging/host-plane.test.ts +132 -0
- package/src/module/packaging/host-plane.ts +135 -0
- package/src/module/packaging/package-rules.ts +62 -0
- package/src/policy/module-business-baseline.ts +0 -11
- package/src/services/alerting/monitors.ts +54 -2
- package/src/services/alerting/sweep-runner.ts +38 -1
- package/src/services/audit/cli-version.test.ts +6 -2
- package/src/services/audit/cli-version.ts +20 -6
- package/src/services/audit/detect-without-converge.test.ts +91 -0
- package/src/services/audit/detect-without-converge.ts +81 -0
- package/src/services/audit/disk-space.test.ts +5 -2
- package/src/services/audit/disk-space.ts +5 -3
- package/src/services/audit/health.test.ts +39 -0
- package/src/services/audit/index.test.ts +7 -1
- package/src/services/audit/index.ts +12 -0
- package/src/services/audit/module-integrity.test.ts +146 -0
- package/src/services/audit/module-integrity.ts +113 -0
- package/src/services/audit/module-versions.ts +4 -1
- package/src/services/audit/schema.test.ts +7 -2
- package/src/services/audit/schema.ts +19 -1
- package/src/services/audit/terraform-plan.ts +17 -2
- package/src/services/audit/types.test.ts +29 -0
- package/src/services/audit/types.ts +30 -4
- package/src/services/consumer-cleanup.ts +5 -3
- package/src/services/container-service.test.ts +34 -0
- package/src/services/container-service.ts +44 -0
- package/src/services/deployed-systems.test.ts +101 -0
- package/src/services/deployed-systems.ts +43 -11
- package/src/services/dns-provider-backfill.ts +30 -0
- package/src/services/fleet-checks.test.ts +26 -0
- package/src/services/fleet-checks.ts +11 -1
- package/src/services/module-deploy.ts +109 -41
- package/src/services/provider-arrival.test.ts +241 -0
- package/src/services/provider-arrival.ts +213 -0
- package/src/services/restore-from-file.ts +4 -0
- package/src/services/update/orchestrator.test.ts +2 -0
- package/src/templates/generator.test.ts +35 -0
- package/src/templates/generator.ts +29 -1
- package/src/variables/context.test.ts +63 -0
- package/src/variables/context.ts +10 -2
- package/src/variables/declarative-derivation.test.ts +47 -8
- package/src/variables/declarative-derivation.ts +6 -4
- package/src/services/public-web-republish.test.ts +0 -189
- package/src/services/public-web-republish.ts +0 -84
|
@@ -3,6 +3,20 @@ import { describe, expect, test } from 'bun:test';
|
|
|
3
3
|
import type { ModuleManifest } from '../manifest/schema';
|
|
4
4
|
import { checkAllowlist, getProviderManifest, validateCapabilityAccess } from './validation';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* A consumer's reference to `dns_external`'s restricted `tsig` secret.
|
|
8
|
+
*
|
|
9
|
+
* The access gate fires only for a secret the consumer actually names
|
|
10
|
+
* (celilo#854), so every fixture that expects a REFUSAL has to name one.
|
|
11
|
+
*/
|
|
12
|
+
const TSIG_REFERENCE = {
|
|
13
|
+
name: 'tsig',
|
|
14
|
+
type: 'string' as const,
|
|
15
|
+
required: false,
|
|
16
|
+
source: 'capability' as const,
|
|
17
|
+
derive_from: '$capability:dns_external.tsig',
|
|
18
|
+
};
|
|
19
|
+
|
|
6
20
|
describe('Capability Access Validation', () => {
|
|
7
21
|
describe('checkAllowlist', () => {
|
|
8
22
|
test('should return true when consumer provides capability in allowlist', () => {
|
|
@@ -452,7 +466,7 @@ describe('Capability Access Validation', () => {
|
|
|
452
466
|
},
|
|
453
467
|
],
|
|
454
468
|
},
|
|
455
|
-
variables: { owns: [], imports: [] },
|
|
469
|
+
variables: { owns: [TSIG_REFERENCE], imports: [] },
|
|
456
470
|
};
|
|
457
471
|
|
|
458
472
|
const providerManifest: ModuleManifest = {
|
|
@@ -510,7 +524,7 @@ describe('Capability Access Validation', () => {
|
|
|
510
524
|
capabilities: [{ name: 'dns_external', version: '1.0.0' }],
|
|
511
525
|
},
|
|
512
526
|
provides: { capabilities: [] }, // No provides section - empty
|
|
513
|
-
variables: { owns: [], imports: [] },
|
|
527
|
+
variables: { owns: [TSIG_REFERENCE], imports: [] },
|
|
514
528
|
};
|
|
515
529
|
|
|
516
530
|
const providerManifest: ModuleManifest = {
|
|
@@ -640,4 +654,175 @@ describe('Capability Access Validation', () => {
|
|
|
640
654
|
expect(callCount).toBe(2); // Should query both capabilities
|
|
641
655
|
});
|
|
642
656
|
});
|
|
657
|
+
|
|
658
|
+
// celilo#854 — the gate is scoped to secrets the consumer actually names.
|
|
659
|
+
//
|
|
660
|
+
// knot-unbound-internal declares dns_internal's `tsig_key` with
|
|
661
|
+
// `readable_by: ["dns_internal"]`. Before this was fixed, ANY module listing
|
|
662
|
+
// dns_internal under `requires.capabilities` and not itself PROVIDING
|
|
663
|
+
// dns_internal was refused at import over a value it never references.
|
|
664
|
+
// caddy-internal was the first module to hit it, and it declared the
|
|
665
|
+
// capability under `optional` to get past the gate — a lie about the
|
|
666
|
+
// dependency graph that six core services read.
|
|
667
|
+
describe('celilo#854 — secret access is gated on reference, not on declaration', () => {
|
|
668
|
+
const knotManifest: ModuleManifest = {
|
|
669
|
+
celilo_contract: '1.0',
|
|
670
|
+
id: 'knot-unbound-internal',
|
|
671
|
+
name: 'Knot + Unbound',
|
|
672
|
+
version: '1.0.0',
|
|
673
|
+
description: 'Test',
|
|
674
|
+
requires: { capabilities: [] },
|
|
675
|
+
provides: {
|
|
676
|
+
capabilities: [
|
|
677
|
+
{
|
|
678
|
+
name: 'dns_internal',
|
|
679
|
+
version: '1.0.0',
|
|
680
|
+
data: {},
|
|
681
|
+
secrets: [
|
|
682
|
+
{
|
|
683
|
+
name: 'tsig_key',
|
|
684
|
+
type: 'string',
|
|
685
|
+
readable_by: ['dns_internal'],
|
|
686
|
+
},
|
|
687
|
+
],
|
|
688
|
+
},
|
|
689
|
+
],
|
|
690
|
+
},
|
|
691
|
+
variables: { owns: [], imports: [] },
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
const knotDb = {
|
|
695
|
+
prepare: () => ({
|
|
696
|
+
get: () => ({ manifest_data: JSON.stringify(knotManifest) }),
|
|
697
|
+
}),
|
|
698
|
+
} as unknown as Database;
|
|
699
|
+
|
|
700
|
+
test('a consumer that requires the capability but never names the secret imports', async () => {
|
|
701
|
+
const manifest: ModuleManifest = {
|
|
702
|
+
celilo_contract: '1.0',
|
|
703
|
+
id: 'caddy-internal',
|
|
704
|
+
name: 'Caddy (fleet-only ingress)',
|
|
705
|
+
version: '1.0.0',
|
|
706
|
+
description: 'Test',
|
|
707
|
+
requires: {
|
|
708
|
+
capabilities: [{ name: 'dns_internal', version: '1.0.0' }],
|
|
709
|
+
},
|
|
710
|
+
provides: {
|
|
711
|
+
capabilities: [{ name: 'private_web', version: '1.0.0', data: {} }],
|
|
712
|
+
},
|
|
713
|
+
variables: { owns: [], imports: [] },
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
const result = await validateCapabilityAccess(manifest, knotDb);
|
|
717
|
+
|
|
718
|
+
expect(result.success).toBe(true);
|
|
719
|
+
expect(result.error).toBeUndefined();
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test('a consumer that DOES name the restricted secret is still refused', async () => {
|
|
723
|
+
const manifest: ModuleManifest = {
|
|
724
|
+
celilo_contract: '1.0',
|
|
725
|
+
id: 'nosy-app',
|
|
726
|
+
name: 'Nosy App',
|
|
727
|
+
version: '1.0.0',
|
|
728
|
+
description: 'Test',
|
|
729
|
+
requires: {
|
|
730
|
+
capabilities: [{ name: 'dns_internal', version: '1.0.0' }],
|
|
731
|
+
},
|
|
732
|
+
provides: {
|
|
733
|
+
capabilities: [{ name: 'private_web', version: '1.0.0', data: {} }],
|
|
734
|
+
},
|
|
735
|
+
variables: {
|
|
736
|
+
owns: [
|
|
737
|
+
{
|
|
738
|
+
name: 'stolen_key',
|
|
739
|
+
type: 'string',
|
|
740
|
+
required: false,
|
|
741
|
+
source: 'capability',
|
|
742
|
+
derive_from: '$capability:dns_internal.tsig_key',
|
|
743
|
+
},
|
|
744
|
+
],
|
|
745
|
+
imports: [],
|
|
746
|
+
},
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
const result = await validateCapabilityAccess(manifest, knotDb);
|
|
750
|
+
|
|
751
|
+
expect(result.success).toBe(false);
|
|
752
|
+
expect(result.error).toContain("Module 'nosy-app' cannot access secret 'tsig_key'");
|
|
753
|
+
expect(result.error).toContain("capability 'dns_internal'");
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* The gate is per-SECRET, not per-capability. A provider that declares two
|
|
758
|
+
* restricted secrets must not put a consumer on the hook for the second
|
|
759
|
+
* one's allow-list just because it named the first — that would move the
|
|
760
|
+
* over-refusal from "requires the capability" down one level to "reads any
|
|
761
|
+
* of its secrets", which is the same conflation wearing a smaller hat.
|
|
762
|
+
*/
|
|
763
|
+
const twoSecretManifest: ModuleManifest = {
|
|
764
|
+
...knotManifest,
|
|
765
|
+
provides: {
|
|
766
|
+
capabilities: [
|
|
767
|
+
{
|
|
768
|
+
name: 'dns_internal',
|
|
769
|
+
version: '1.0.0',
|
|
770
|
+
data: {},
|
|
771
|
+
secrets: [
|
|
772
|
+
{ name: 'tsig_key', type: 'string', readable_by: ['dns_internal'] },
|
|
773
|
+
{ name: 'api_token', type: 'string', readable_by: ['private_web'] },
|
|
774
|
+
],
|
|
775
|
+
},
|
|
776
|
+
],
|
|
777
|
+
},
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
const twoSecretDb = {
|
|
781
|
+
prepare: () => ({
|
|
782
|
+
get: () => ({ manifest_data: JSON.stringify(twoSecretManifest) }),
|
|
783
|
+
}),
|
|
784
|
+
} as unknown as Database;
|
|
785
|
+
|
|
786
|
+
test('naming one secret enforces that secret allow-list and no other', async () => {
|
|
787
|
+
function consumer(deriveFrom: string): ModuleManifest {
|
|
788
|
+
return {
|
|
789
|
+
celilo_contract: '1.0',
|
|
790
|
+
id: 'caddy-internal',
|
|
791
|
+
name: 'Caddy (fleet-only ingress)',
|
|
792
|
+
version: '1.0.0',
|
|
793
|
+
description: 'Test',
|
|
794
|
+
requires: { capabilities: [{ name: 'dns_internal', version: '1.0.0' }] },
|
|
795
|
+
provides: { capabilities: [{ name: 'private_web', version: '1.0.0', data: {} }] },
|
|
796
|
+
variables: {
|
|
797
|
+
owns: [
|
|
798
|
+
{
|
|
799
|
+
name: 'borrowed',
|
|
800
|
+
type: 'string',
|
|
801
|
+
required: false,
|
|
802
|
+
source: 'capability',
|
|
803
|
+
derive_from: deriveFrom,
|
|
804
|
+
},
|
|
805
|
+
],
|
|
806
|
+
imports: [],
|
|
807
|
+
},
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// `api_token` is readable by `private_web`, which this consumer provides.
|
|
812
|
+
// The unnamed `tsig_key`, which it does not satisfy, must not interfere.
|
|
813
|
+
const allowed = await validateCapabilityAccess(
|
|
814
|
+
consumer('$capability:dns_internal.api_token'),
|
|
815
|
+
twoSecretDb,
|
|
816
|
+
);
|
|
817
|
+
expect(allowed.success).toBe(true);
|
|
818
|
+
|
|
819
|
+
// Naming `tsig_key` is still refused, by name.
|
|
820
|
+
const refused = await validateCapabilityAccess(
|
|
821
|
+
consumer('$capability:dns_internal.tsig_key'),
|
|
822
|
+
twoSecretDb,
|
|
823
|
+
);
|
|
824
|
+
expect(refused.success).toBe(false);
|
|
825
|
+
expect(refused.error).toContain('tsig_key');
|
|
826
|
+
});
|
|
827
|
+
});
|
|
643
828
|
});
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { Database } from 'bun:sqlite';
|
|
7
7
|
import type { ModuleManifest } from '../manifest/schema';
|
|
8
8
|
import { isPrivilegedCapability } from '../manifest/validate';
|
|
9
|
+
import { parseVariables } from '../variables/parser';
|
|
9
10
|
|
|
10
11
|
export interface ValidationResult {
|
|
11
12
|
success: boolean;
|
|
@@ -34,6 +35,8 @@ export async function validateCapabilityAccess(
|
|
|
34
35
|
// Get list of capabilities this module provides
|
|
35
36
|
const consumerCapabilities = (manifest.provides?.capabilities || []).map((cap) => cap.name);
|
|
36
37
|
|
|
38
|
+
const references = collectCapabilityReferences(manifest);
|
|
39
|
+
|
|
37
40
|
// Check each required capability
|
|
38
41
|
for (const requiredCapability of manifest.requires.capabilities) {
|
|
39
42
|
// Framework-granted privileges (e.g. cross_module_read) are not
|
|
@@ -63,8 +66,12 @@ export async function validateCapabilityAccess(
|
|
|
63
66
|
continue;
|
|
64
67
|
}
|
|
65
68
|
|
|
66
|
-
// Check allowlist for each secret
|
|
69
|
+
// Check allowlist for each secret the consumer actually names (celilo#854).
|
|
67
70
|
for (const secret of capabilityDef.secrets) {
|
|
71
|
+
if (!referencesSecret(references, requiredCapability.name, secret.name)) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
68
75
|
if (secret.readable_by && secret.readable_by.length > 0) {
|
|
69
76
|
// Check if consumer provides any capability in the allowlist
|
|
70
77
|
const hasAccess = checkAllowlist(consumerCapabilities, secret.readable_by);
|
|
@@ -89,6 +96,51 @@ export async function validateCapabilityAccess(
|
|
|
89
96
|
return { success: true };
|
|
90
97
|
}
|
|
91
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Every `$capability:<name>.<path>` reference the consumer's manifest makes.
|
|
101
|
+
*
|
|
102
|
+
* Policy function (Rule 10.1) - parses only, no I/O.
|
|
103
|
+
*
|
|
104
|
+
* Serializing the manifest and parsing the result finds a reference wherever it
|
|
105
|
+
* lives — a `variables.owns[].derive_from`, a default, a capability data block —
|
|
106
|
+
* without this having to track which fields may hold one.
|
|
107
|
+
*
|
|
108
|
+
* References made from a module's TEMPLATES are deliberately not collected here.
|
|
109
|
+
* `src/variables/resolver.ts` checks access at the point of use, which is the
|
|
110
|
+
* only place a template reference can be seen, and it refuses there.
|
|
111
|
+
*/
|
|
112
|
+
function collectCapabilityReferences(manifest: ModuleManifest): Set<string> {
|
|
113
|
+
return new Set(
|
|
114
|
+
parseVariables(JSON.stringify(manifest))
|
|
115
|
+
.filter((variable) => variable.type === 'capability')
|
|
116
|
+
.map((variable) => variable.path),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Does the consumer name this capability secret?
|
|
122
|
+
*
|
|
123
|
+
* Policy function (Rule 10.1) - pure logic, no I/O.
|
|
124
|
+
*
|
|
125
|
+
* A reference of `dns_internal.tsig_key` names the `tsig_key` secret, and so
|
|
126
|
+
* does `dns_internal.tsig_key.value` if the secret ever holds a structure.
|
|
127
|
+
* Requiring the capability alone names nothing (celilo#854): a module refused
|
|
128
|
+
* over a secret it never reads has to lie about its dependency graph to deploy.
|
|
129
|
+
*/
|
|
130
|
+
function referencesSecret(
|
|
131
|
+
references: Set<string>,
|
|
132
|
+
capabilityName: string,
|
|
133
|
+
secretName: string,
|
|
134
|
+
): boolean {
|
|
135
|
+
const base = `${capabilityName}.${secretName}`;
|
|
136
|
+
for (const reference of references) {
|
|
137
|
+
if (reference === base || reference.startsWith(`${base}.`)) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
92
144
|
/**
|
|
93
145
|
* Check if consumer capabilities match provider allowlist
|
|
94
146
|
*
|
|
@@ -89,6 +89,12 @@ export async function handleAlertsSweep(): Promise<CommandResult> {
|
|
|
89
89
|
if (report.deferredDelivered > 0) parts.push(`${report.deferredDelivered} deferred-delivered`);
|
|
90
90
|
if (report.failed > 0) parts.push(`${report.failed} FAILED`);
|
|
91
91
|
if (report.noPolicy.length > 0) parts.push(`${report.noPolicy.length} no-policy`);
|
|
92
|
+
// A monitor whose module is gone is dropped here rather than left to sit
|
|
93
|
+
// unschedulable forever (celilo#1029).
|
|
94
|
+
if (report.strandedDropped.length > 0) {
|
|
95
|
+
const names = report.strandedDropped.map((d) => d.moduleId).join(', ');
|
|
96
|
+
parts.push(`${report.strandedDropped.length} stranded-dropped (${names})`);
|
|
97
|
+
}
|
|
92
98
|
|
|
93
99
|
const lines = [`alert sweep: ${parts.join(', ')}`];
|
|
94
100
|
|
|
@@ -109,6 +115,18 @@ export async function handleAlertsSweep(): Promise<CommandResult> {
|
|
|
109
115
|
lines.push(` ${alertKey} (${reason})`);
|
|
110
116
|
}
|
|
111
117
|
}
|
|
118
|
+
// A dropped monitor's alerts are deleted with it, not resolved — nothing else
|
|
119
|
+
// anywhere records that they existed. Name each one and what it said: the
|
|
120
|
+
// failure it was reporting is real and is now unwatched, which is the fact an
|
|
121
|
+
// operator has to act on and the one a module name alone does not carry.
|
|
122
|
+
for (const { moduleId, alerts: dropped } of report.strandedDropped) {
|
|
123
|
+
if (dropped.length === 0) continue;
|
|
124
|
+
lines.push(` ${moduleId} is gone; its monitor was holding ${dropped.length} live alert(s):`);
|
|
125
|
+
for (const alert of dropped) {
|
|
126
|
+
lines.push(` ${alert.key}: ${alert.message}`);
|
|
127
|
+
}
|
|
128
|
+
lines.push(` nothing checks ${moduleId} any more, so these will not be reported again.`);
|
|
129
|
+
}
|
|
112
130
|
// The error itself, not just a count: the transport is loaded lazily inside
|
|
113
131
|
// the send, so a capability that will not load produces no other record
|
|
114
132
|
// anywhere — nothing ever reaches the transport's own logs.
|
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
import type { CommandResult } from '../types';
|
|
14
14
|
import { moduleVerify } from './module-verify';
|
|
15
15
|
|
|
16
|
-
export async function moduleAudit(
|
|
16
|
+
export async function moduleAudit(
|
|
17
|
+
args: string[],
|
|
18
|
+
flags: Record<string, string | boolean> = {},
|
|
19
|
+
): Promise<CommandResult> {
|
|
17
20
|
process.stderr.write(
|
|
18
21
|
'warning: `celilo module audit` is deprecated; use `celilo module verify` instead.\n',
|
|
19
22
|
);
|
|
20
|
-
return moduleVerify(args);
|
|
23
|
+
return moduleVerify(args, flags);
|
|
21
24
|
}
|
|
@@ -15,6 +15,7 @@ import { createGaugeLogger } from '../../hooks/logger';
|
|
|
15
15
|
import { runNamedHook } from '../../hooks/run-named-hook';
|
|
16
16
|
import { deallocateForModule } from '../../ipam/auto-allocator';
|
|
17
17
|
import { type ModuleManifest, ModuleManifestSchema } from '../../manifest/schema';
|
|
18
|
+
import { deleteMonitorForModule } from '../../services/alerting/monitors';
|
|
18
19
|
import { executeBuildWithProgress } from '../../services/build-stream';
|
|
19
20
|
import { askConfirm, withInterviewSession } from '../../services/bus-interview';
|
|
20
21
|
import {
|
|
@@ -22,7 +23,11 @@ import {
|
|
|
22
23
|
emitUninstallFailed,
|
|
23
24
|
emitUninstallStarted,
|
|
24
25
|
} from '../../services/celilo-events';
|
|
25
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
PRE_DEPLOY_STATES,
|
|
28
|
+
loadConsumerCleanupPlan,
|
|
29
|
+
runConsumerCleanup,
|
|
30
|
+
} from '../../services/consumer-cleanup';
|
|
26
31
|
import { getContainerService, getServiceCredentials } from '../../services/container-service';
|
|
27
32
|
import { completeOperation, failOperation, startOperation } from '../../services/module-operations';
|
|
28
33
|
import {
|
|
@@ -97,7 +102,7 @@ export async function handleModuleRemove(
|
|
|
97
102
|
id: m.id,
|
|
98
103
|
manifest: parsed.data,
|
|
99
104
|
paused: m.state === 'PAUSED',
|
|
100
|
-
deployed: !
|
|
105
|
+
deployed: !PRE_DEPLOY_STATES.has(m.state),
|
|
101
106
|
});
|
|
102
107
|
}
|
|
103
108
|
|
|
@@ -403,6 +408,33 @@ async function performModuleRemove(
|
|
|
403
408
|
log.warn(`Failed to unregister event-bus subscriptions: ${msg}`);
|
|
404
409
|
}
|
|
405
410
|
|
|
411
|
+
// Drop the module's health monitor. No cascade can reach it — `monitors.target`
|
|
412
|
+
// holds a module id or an audit check name depending on `kind`, so the column
|
|
413
|
+
// carries no foreign key (celilo#1029). Left behind, the monitor fires
|
|
414
|
+
// `Module not found` and then becomes permanently unschedulable, because its
|
|
415
|
+
// cadence resolves from a module row that no longer exists — so nothing ever
|
|
416
|
+
// runs it again to resolve the alert it just raised.
|
|
417
|
+
//
|
|
418
|
+
// What it was holding is named, not just counted. The alerts are deleted with
|
|
419
|
+
// it (cascade), so a firing check goes silent and the coverage of it goes at
|
|
420
|
+
// the same moment — an operator who is told only `removed monitor` never
|
|
421
|
+
// learns which real failure just stopped being reported.
|
|
422
|
+
try {
|
|
423
|
+
const droppedAlerts = deleteMonitorForModule(db, moduleId);
|
|
424
|
+
if (droppedAlerts) {
|
|
425
|
+
log.info(`Removed health monitor for ${moduleId}`);
|
|
426
|
+
for (const alert of droppedAlerts) {
|
|
427
|
+
log.warn(` dropped live alert ${alert.key}: ${alert.message}`);
|
|
428
|
+
}
|
|
429
|
+
if (droppedAlerts.length > 0) {
|
|
430
|
+
log.warn(` nothing checks ${moduleId} any more, so this will not be reported again.`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
} catch (error) {
|
|
434
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
435
|
+
log.warn(`Failed to remove health monitor for ${moduleId}: ${msg}`);
|
|
436
|
+
}
|
|
437
|
+
|
|
406
438
|
// Delete module (cascade will remove configs, secrets, capabilities, infrastructure records)
|
|
407
439
|
db.delete(modules).where(eq(modules.id, moduleId)).run();
|
|
408
440
|
|
|
@@ -5,12 +5,21 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
8
|
-
import {
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import {
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
mkdtempSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
readdirSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from 'node:fs';
|
|
9
18
|
import { tmpdir } from 'node:os';
|
|
10
|
-
import { join } from 'node:path';
|
|
19
|
+
import { join, relative } from 'node:path';
|
|
11
20
|
import { eq } from 'drizzle-orm';
|
|
12
21
|
import { type DbClient, getDb } from '../../db/client';
|
|
13
|
-
import { modules } from '../../db/schema';
|
|
22
|
+
import { moduleIntegrity, modules } from '../../db/schema';
|
|
14
23
|
import { classifyVersionChange, handleModuleUpdate, updateOne } from './module-update';
|
|
15
24
|
|
|
16
25
|
describe('classifyVersionChange', () => {
|
|
@@ -321,3 +330,229 @@ describe('registry sweep — an unanswered breaking update is not a decline', ()
|
|
|
321
330
|
expect(getDb().select().from(modules).all()[0].version).toBe('1.0.2+9');
|
|
322
331
|
});
|
|
323
332
|
});
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Every file under `root`, keyed by relative path, valued by its exact bytes.
|
|
336
|
+
* Compared whole so a TRUNCATION shows up — an existence check would not.
|
|
337
|
+
*/
|
|
338
|
+
function snapshot(root: string, dir = root): Record<string, string> {
|
|
339
|
+
const files: Record<string, string> = {};
|
|
340
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
341
|
+
const full = join(dir, entry.name);
|
|
342
|
+
if (entry.isDirectory()) Object.assign(files, snapshot(root, full));
|
|
343
|
+
else if (entry.isFile()) files[relative(root, full)] = readFileSync(full, 'utf-8');
|
|
344
|
+
}
|
|
345
|
+
return files;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
describe("updateOne — pointed at the module's own installed path", () => {
|
|
349
|
+
let tempDir: string;
|
|
350
|
+
let installedDir: string;
|
|
351
|
+
let db: DbClient;
|
|
352
|
+
|
|
353
|
+
beforeEach(() => {
|
|
354
|
+
tempDir = mkdtempSync(join(tmpdir(), 'celilo-selfpath-'));
|
|
355
|
+
process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
|
|
356
|
+
process.env.CELILO_ORIGINAL_CWD = tempDir;
|
|
357
|
+
|
|
358
|
+
installedDir = join(tempDir, 'installed', 'selfmod');
|
|
359
|
+
mkdirSync(installedDir, { recursive: true });
|
|
360
|
+
writeFileSync(
|
|
361
|
+
join(installedDir, 'manifest.yml'),
|
|
362
|
+
`celilo_contract: "1.0"
|
|
363
|
+
id: selfmod
|
|
364
|
+
name: Self Module
|
|
365
|
+
version: 1.0.0
|
|
366
|
+
description: fixture
|
|
367
|
+
`,
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
db = getDb();
|
|
371
|
+
db.insert(modules)
|
|
372
|
+
.values({
|
|
373
|
+
id: 'selfmod',
|
|
374
|
+
name: 'Self Module',
|
|
375
|
+
sourcePath: installedDir,
|
|
376
|
+
version: '1.0.0',
|
|
377
|
+
manifestData: {
|
|
378
|
+
celilo_contract: '1.0',
|
|
379
|
+
id: 'selfmod',
|
|
380
|
+
name: 'Self Module',
|
|
381
|
+
version: '1.0.0',
|
|
382
|
+
},
|
|
383
|
+
})
|
|
384
|
+
.run();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
afterEach(() => {
|
|
388
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
389
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
390
|
+
process.env.CELILO_ORIGINAL_CWD = undefined;
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test('refuses by name, and writes NOTHING', async () => {
|
|
394
|
+
// A data-loss guard, not a UX nicety. On celilo-mgr on 2026-08-19,
|
|
395
|
+
// `celilo module update /var/celilo/modules/wireguard-manager` threw
|
|
396
|
+
// `EINVAL: copy_file_range` and TRUNCATED
|
|
397
|
+
// `ansible/roles/wireguard-manager/handlers/main.yml` in the module source
|
|
398
|
+
// on the way down. The crash was mid-copy, so the tree was left damaged
|
|
399
|
+
// rather than untouched. It stayed invisible for nine hours because
|
|
400
|
+
// `generated/` still held a good copy; the next generate propagated the
|
|
401
|
+
// truncation, and the deploy after that died with "The requested handler
|
|
402
|
+
// 'Restart wireguard-manager' was not found". App health, ingress and
|
|
403
|
+
// tunnel peers were all green throughout — none of them can see a
|
|
404
|
+
// truncated Ansible handler.
|
|
405
|
+
//
|
|
406
|
+
// So this compares the whole tree byte for byte rather than checking that
|
|
407
|
+
// a file still exists. "It threw" was already true of the behaviour that
|
|
408
|
+
// caused the damage, and an existence check cannot see a truncation.
|
|
409
|
+
const before = snapshot(installedDir);
|
|
410
|
+
|
|
411
|
+
const result = await updateOne(installedDir, db, {}, { quiet: true });
|
|
412
|
+
|
|
413
|
+
expect(result.status).toBe('failed');
|
|
414
|
+
if (result.status !== 'failed') return;
|
|
415
|
+
expect(result.error).toContain('IS the installed copy of selfmod');
|
|
416
|
+
expect(result.error).not.toContain('EINVAL');
|
|
417
|
+
|
|
418
|
+
expect(snapshot(installedDir)).toEqual(before);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* celilo#1008. `updateOne` copied the new tree onto the installed one in
|
|
424
|
+
* place, so a copy that died partway left a module half old and half new,
|
|
425
|
+
* with no record anywhere of which files were which. Jeremy Banka's
|
|
426
|
+
* `f9a57f1b` staged into a sibling and swapped with two renames; the rest of
|
|
427
|
+
* that commit is superseded by celilo#925, but the atomicity is not, and this
|
|
428
|
+
* is where it lands.
|
|
429
|
+
*
|
|
430
|
+
* The failure is provoked rather than injected, so nothing test-only reaches
|
|
431
|
+
* production code. The source carries a FIFO, which `cpSync` refuses with
|
|
432
|
+
* ENOTSUP — a stand-in for any mid-copy failure a real update can hit (a full
|
|
433
|
+
* disk, a permission, an I/O error). It is chosen because it fails on the
|
|
434
|
+
* SOURCE, so it fires whether the copy targets the live install or a staging
|
|
435
|
+
* directory, which a destination-side collision would not. `z-` sorts last,
|
|
436
|
+
* so the entries ahead of it copy successfully first — precisely the
|
|
437
|
+
* half-applied state being ruled out.
|
|
438
|
+
*/
|
|
439
|
+
describe('updateOne — an update that fails partway leaves the install untouched', () => {
|
|
440
|
+
let tempDir: string;
|
|
441
|
+
let srcDir: string;
|
|
442
|
+
let installedDir: string;
|
|
443
|
+
let db: DbClient;
|
|
444
|
+
|
|
445
|
+
/** Every file under `root`, relative path → bytes, for an exact comparison. */
|
|
446
|
+
function snapshotTree(root: string, dir = root): Record<string, string> {
|
|
447
|
+
const out: Record<string, string> = {};
|
|
448
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
449
|
+
const full = join(dir, entry.name);
|
|
450
|
+
if (entry.isDirectory()) Object.assign(out, snapshotTree(root, full));
|
|
451
|
+
else out[relative(root, full)] = readFileSync(full, 'utf-8');
|
|
452
|
+
}
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
beforeEach(() => {
|
|
457
|
+
tempDir = mkdtempSync(join(tmpdir(), 'celilo-atomic-'));
|
|
458
|
+
process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
|
|
459
|
+
process.env.CELILO_ORIGINAL_CWD = tempDir;
|
|
460
|
+
|
|
461
|
+
installedDir = join(tempDir, 'installed', 'testmod');
|
|
462
|
+
mkdirSync(join(installedDir, 'scripts'), { recursive: true });
|
|
463
|
+
mkdirSync(join(installedDir, 'generated'), { recursive: true });
|
|
464
|
+
writeFileSync(
|
|
465
|
+
join(installedDir, 'manifest.yml'),
|
|
466
|
+
'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
|
|
467
|
+
);
|
|
468
|
+
writeFileSync(join(installedDir, 'scripts', 'on_install.ts'), 'export const OLD = 1;\n');
|
|
469
|
+
// `derived` — celilo's own output, must survive an update either way.
|
|
470
|
+
writeFileSync(join(installedDir, 'generated', 'terraform.tfstate'), '{"old":true}\n');
|
|
471
|
+
|
|
472
|
+
srcDir = join(tempDir, 'src');
|
|
473
|
+
mkdirSync(join(srcDir, 'scripts'), { recursive: true });
|
|
474
|
+
writeFileSync(
|
|
475
|
+
join(srcDir, 'manifest.yml'),
|
|
476
|
+
'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 2.0.0\ndescription: fixture\n',
|
|
477
|
+
);
|
|
478
|
+
writeFileSync(join(srcDir, 'scripts', 'on_install.ts'), 'export const NEW = 2;\n');
|
|
479
|
+
// cpSync refuses a FIFO with ENOTSUP. Sorts last, so the real files copy first.
|
|
480
|
+
execFileSync('mkfifo', [join(srcDir, 'z-boom')]);
|
|
481
|
+
|
|
482
|
+
db = getDb();
|
|
483
|
+
db.insert(modules)
|
|
484
|
+
.values({
|
|
485
|
+
id: 'testmod',
|
|
486
|
+
name: 'Test Module',
|
|
487
|
+
sourcePath: installedDir,
|
|
488
|
+
version: '1.0.0',
|
|
489
|
+
manifestData: {
|
|
490
|
+
celilo_contract: '1.0',
|
|
491
|
+
id: 'testmod',
|
|
492
|
+
name: 'Test Module',
|
|
493
|
+
version: '1.0.0',
|
|
494
|
+
},
|
|
495
|
+
})
|
|
496
|
+
.run();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
afterEach(() => {
|
|
500
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
501
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
502
|
+
process.env.CELILO_ORIGINAL_CWD = undefined;
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test('a failed update leaves the installed tree byte-identical', async () => {
|
|
506
|
+
const before = snapshotTree(installedDir);
|
|
507
|
+
expect(before['scripts/on_install.ts']).toBe('export const OLD = 1;\n');
|
|
508
|
+
|
|
509
|
+
await expect(updateOne(srcDir, db, {}, { quiet: true })).rejects.toThrow();
|
|
510
|
+
|
|
511
|
+
expect(snapshotTree(installedDir)).toEqual(before);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test('a successful update keeps celilo output and drops what the version removed', async () => {
|
|
515
|
+
// Same fixture minus the FIFO, so the update runs to completion.
|
|
516
|
+
rmSync(join(srcDir, 'z-boom'));
|
|
517
|
+
// A file the previous version shipped and the new one does not.
|
|
518
|
+
writeFileSync(join(installedDir, 'scripts', 'gone_in_2.ts'), 'export const OLD = 1;\n');
|
|
519
|
+
|
|
520
|
+
const result = await updateOne(srcDir, db, {}, { quiet: true });
|
|
521
|
+
expect(result.status).toBe('success');
|
|
522
|
+
|
|
523
|
+
// `derived`: celilo's own output survives (task 11.6).
|
|
524
|
+
expect(readFileSync(join(installedDir, 'generated', 'terraform.tfstate'), 'utf-8')).toBe(
|
|
525
|
+
'{"old":true}\n',
|
|
526
|
+
);
|
|
527
|
+
// `package`: the new version's content landed...
|
|
528
|
+
expect(readFileSync(join(installedDir, 'scripts', 'on_install.ts'), 'utf-8')).toBe(
|
|
529
|
+
'export const NEW = 2;\n',
|
|
530
|
+
);
|
|
531
|
+
// ...and what it dropped is gone, pruned in staging rather than in place.
|
|
532
|
+
expect(existsSync(join(installedDir, 'scripts', 'gone_in_2.ts'))).toBe(false);
|
|
533
|
+
// No staging debris on the success path either.
|
|
534
|
+
expect(readdirSync(join(tempDir, 'installed'))).toEqual(['testmod']);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test('a failed update leaves no staging directory behind', async () => {
|
|
538
|
+
await expect(updateOne(srcDir, db, {}, { quiet: true })).rejects.toThrow();
|
|
539
|
+
|
|
540
|
+
const siblings = readdirSync(join(tempDir, 'installed'));
|
|
541
|
+
expect(siblings).toEqual(['testmod']);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('a failed update does not advance the integrity baseline', async () => {
|
|
545
|
+
await expect(updateOne(srcDir, db, {}, { quiet: true })).rejects.toThrow();
|
|
546
|
+
|
|
547
|
+
const row = db
|
|
548
|
+
.select()
|
|
549
|
+
.from(moduleIntegrity)
|
|
550
|
+
.where(eq(moduleIntegrity.moduleId, 'testmod'))
|
|
551
|
+
.get();
|
|
552
|
+
// Nothing recorded at all: the update never reached a state worth claiming.
|
|
553
|
+
expect(row).toBeUndefined();
|
|
554
|
+
// And the module row still names the version actually on disk.
|
|
555
|
+
const mod = db.select().from(modules).where(eq(modules.id, 'testmod')).get();
|
|
556
|
+
expect(mod?.version).toBe('1.0.0');
|
|
557
|
+
});
|
|
558
|
+
});
|