@celilo/cli 0.1.4 → 0.1.6
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/drizzle/0004_caddy_hostname_list.sql +25 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +9 -2
- package/src/ansible/inventory.test.ts +9 -8
- package/src/ansible/inventory.ts +9 -7
- package/src/capabilities/public-web-helpers.test.ts +2 -2
- package/src/capabilities/public-web-publish.test.ts +45 -12
- package/src/capabilities/registration.test.ts +6 -6
- package/src/capabilities/well-known.test.ts +2 -2
- package/src/capabilities/well-known.ts +5 -5
- package/src/cli/cli.test.ts +2 -2
- package/src/cli/command-registry.ts +146 -3
- package/src/cli/command-tree-parser.test.ts +1 -1
- package/src/cli/command-tree-parser.ts +9 -8
- package/src/cli/commands/hook-run.ts +15 -66
- package/src/cli/commands/module-audit.ts +14 -44
- package/src/cli/commands/module-deploy.ts +4 -1
- package/src/cli/commands/module-import-registry.test.ts +115 -0
- package/src/cli/commands/module-import.ts +106 -22
- package/src/cli/commands/module-publish.test.ts +235 -0
- package/src/cli/commands/module-publish.ts +234 -0
- package/src/cli/commands/module-remove.ts +82 -2
- package/src/cli/commands/module-search.ts +57 -0
- package/src/cli/commands/module-secret-get.ts +59 -0
- package/src/cli/commands/module-show.ts +1 -1
- package/src/cli/commands/module-terraform-unlock.ts +57 -0
- package/src/cli/commands/module-verify.test.ts +59 -0
- package/src/cli/commands/module-verify.ts +53 -0
- package/src/cli/commands/status.ts +30 -20
- package/src/cli/commands/system-audit.test.ts +138 -0
- package/src/cli/commands/system-audit.ts +571 -0
- package/src/cli/commands/system-update.ts +391 -0
- package/src/cli/completion.ts +15 -1
- package/src/cli/fuel-gauge.ts +68 -3
- package/src/cli/generate-zsh-completion.ts +13 -3
- package/src/cli/index.ts +112 -5
- package/src/cli/parser.ts +11 -0
- package/src/cli/prompts.ts +36 -5
- package/src/cli/tui/audit-state.test.ts +246 -0
- package/src/cli/tui/audit-state.ts +525 -0
- package/src/cli/tui/audit-tui.test.tsx +135 -0
- package/src/cli/tui/audit-tui.tsx +624 -0
- package/src/cli/tui/celebration.tsx +29 -0
- package/src/cli/tui/clipboard.test.ts +94 -0
- package/src/cli/tui/clipboard.ts +101 -0
- package/src/cli/tui/icons.ts +22 -0
- package/src/cli/tui/keybar.tsx +65 -0
- package/src/cli/tui/keymap.test.ts +105 -0
- package/src/cli/tui/keymap.ts +70 -0
- package/src/cli/tui/modals/analyzing.tsx +75 -0
- package/src/cli/tui/modals/celebration.tsx +44 -0
- package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
- package/src/cli/tui/modals/remediate.tsx +44 -0
- package/src/cli/tui/modals.test.ts +137 -0
- package/src/cli/tui/mouse.test.ts +78 -0
- package/src/cli/tui/mouse.ts +114 -0
- package/src/cli/tui/panes/categories.tsx +62 -0
- package/src/cli/tui/panes/command-log.tsx +87 -0
- package/src/cli/tui/panes/detail.tsx +175 -0
- package/src/cli/tui/panes/findings.tsx +97 -0
- package/src/cli/tui/panes/summary.tsx +64 -0
- package/src/cli/tui/spawn.ts +130 -0
- package/src/cli/tui/theme.ts +42 -0
- package/src/cli/tui/wrap.test.ts +43 -0
- package/src/cli/tui/wrap.ts +45 -0
- package/src/cli/types.ts +5 -0
- package/src/db/client.ts +55 -2
- package/src/db/schema.test.ts +3 -3
- package/src/db/schema.ts +26 -17
- package/src/hooks/capability-loader.ts +135 -72
- package/src/hooks/define-hook.test.ts +11 -3
- package/src/hooks/executor.ts +22 -1
- package/src/hooks/load-hook-config.test.ts +165 -0
- package/src/hooks/load-hook-config.ts +60 -0
- package/src/hooks/logger.ts +42 -12
- package/src/hooks/run-named-hook.ts +128 -0
- package/src/hooks/types.ts +19 -0
- package/src/manifest/ensure-schema.test.ts +115 -0
- package/src/manifest/schema.ts +76 -0
- package/src/manifest/template-validator.test.ts +1 -1
- package/src/manifest/template-validator.ts +1 -1
- package/src/manifest/validate.test.ts +1 -1
- package/src/module/import.ts +20 -12
- package/src/module/packaging/build.ts +121 -25
- package/src/module/packaging/release-metadata.test.ts +103 -0
- package/src/module/packaging/release-metadata.ts +145 -0
- package/src/registry/client.test.ts +228 -0
- package/src/registry/client.ts +157 -0
- package/src/services/audit/backups.test.ts +233 -0
- package/src/services/audit/backups.ts +128 -0
- package/src/services/audit/capability-abi.test.ts +153 -0
- package/src/services/audit/capability-abi.ts +204 -0
- package/src/services/audit/cli-version.test.ts +60 -0
- package/src/services/audit/cli-version.ts +87 -0
- package/src/services/audit/health.test.ts +84 -0
- package/src/services/audit/health.ts +43 -0
- package/src/services/audit/index.test.ts +99 -0
- package/src/services/audit/index.ts +118 -0
- package/src/services/audit/machines-reachable.test.ts +87 -0
- package/src/services/audit/machines-reachable.ts +87 -0
- package/src/services/audit/module-configs.test.ts +131 -0
- package/src/services/audit/module-configs.ts +80 -0
- package/src/services/audit/module-versions.test.ts +99 -0
- package/src/services/audit/module-versions.ts +154 -0
- package/src/services/audit/schema.test.ts +68 -0
- package/src/services/audit/schema.ts +115 -0
- package/src/services/audit/secrets-decryptable.test.ts +82 -0
- package/src/services/audit/secrets-decryptable.ts +97 -0
- package/src/services/audit/services-credentials.test.ts +54 -0
- package/src/services/audit/services-credentials.ts +64 -0
- package/src/services/audit/services-reachable.test.ts +60 -0
- package/src/services/audit/services-reachable.ts +64 -0
- package/src/services/audit/terraform-plan.test.ts +127 -0
- package/src/services/audit/terraform-plan.ts +153 -0
- package/src/services/audit/types.test.ts +36 -0
- package/src/services/audit/types.ts +90 -0
- package/src/services/audit/unconfigured-modules.test.ts +48 -0
- package/src/services/audit/unconfigured-modules.ts +71 -0
- package/src/services/audit/undeployed-modules.test.ts +66 -0
- package/src/services/audit/undeployed-modules.ts +72 -0
- package/src/services/build-stream.ts +122 -122
- package/src/services/config-interview.ts +407 -2
- package/src/services/deploy-ansible.ts +73 -7
- package/src/services/deploy-planner.ts +5 -5
- package/src/services/deploy-preflight.ts +45 -4
- package/src/services/deploy-terraform.ts +31 -24
- package/src/services/deploy-validation.ts +167 -23
- package/src/services/dns-auto-register.ts +4 -4
- package/src/services/ensure-interview.test.ts +245 -0
- package/src/services/health-runner.ts +110 -38
- package/src/services/infrastructure-variable-resolver.test.ts +1 -1
- package/src/services/infrastructure-variable-resolver.ts +3 -3
- package/src/services/module-build.ts +11 -13
- package/src/services/module-deploy.ts +372 -61
- package/src/services/proxmox-state-recovery.ts +6 -6
- package/src/services/ssh-key-manager.test.ts +1 -1
- package/src/services/ssh-key-manager.ts +3 -2
- package/src/services/terraform-env.ts +62 -0
- package/src/services/update/dep-graph.test.ts +214 -0
- package/src/services/update/dep-graph.ts +215 -0
- package/src/services/update/orchestrator.test.ts +463 -0
- package/src/services/update/orchestrator.ts +359 -0
- package/src/services/update/progress.ts +49 -0
- package/src/services/update/self-update.test.ts +68 -0
- package/src/services/update/self-update.ts +57 -0
- package/src/services/update/types.ts +94 -0
- package/src/templates/generator.test.ts +3 -3
- package/src/templates/generator.ts +43 -2
- package/src/test-utils/completion-harness.test.ts +1 -1
- package/src/test-utils/completion-harness.ts +4 -4
- package/src/variables/capability-self-ref.test.ts +203 -0
- package/src/variables/context.test.ts +31 -31
- package/src/variables/context.ts +65 -17
- package/src/variables/declarative-derivation.test.ts +306 -0
- package/src/variables/declarative-derivation.ts +4 -2
- package/src/variables/parser.test.ts +64 -9
- package/src/variables/parser.ts +47 -6
- package/src/variables/resolver.test.ts +14 -14
- package/src/variables/resolver.ts +27 -9
- package/src/variables/types.ts +1 -1
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose `TF_VAR_*` environment variables from a module's bound
|
|
3
|
+
* container-service credentials. Shared between `module-deploy`
|
|
4
|
+
* (which executes terraform during deployment) and `system audit`'s
|
|
5
|
+
* terraform-plan check (which needs the same credentials to run a
|
|
6
|
+
* non-mutating `terraform plan`).
|
|
7
|
+
*
|
|
8
|
+
* Modules bound to a machine instead of a container service get an
|
|
9
|
+
* empty record — there are no provider credentials to inject.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { eq } from 'drizzle-orm';
|
|
13
|
+
import type { DbClient } from '../db/client';
|
|
14
|
+
import { moduleInfrastructure } from '../db/schema';
|
|
15
|
+
import { getContainerService, getServiceCredentials } from './container-service';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build the `TF_VAR_*` env-var record for a single module. Returns
|
|
19
|
+
* an empty record when the module isn't bound to a container service
|
|
20
|
+
* (e.g. machine-pool deployments) or when credentials aren't
|
|
21
|
+
* available. Never throws — failure to look up credentials is
|
|
22
|
+
* surfaced as a missing var, which terraform will then complain
|
|
23
|
+
* about in a way the caller already handles.
|
|
24
|
+
*/
|
|
25
|
+
export async function buildTerraformEnvForModule(
|
|
26
|
+
moduleId: string,
|
|
27
|
+
db: DbClient,
|
|
28
|
+
): Promise<Record<string, string>> {
|
|
29
|
+
const infra = db
|
|
30
|
+
.select()
|
|
31
|
+
.from(moduleInfrastructure)
|
|
32
|
+
.where(eq(moduleInfrastructure.moduleId, moduleId))
|
|
33
|
+
.get();
|
|
34
|
+
if (!infra || infra.infrastructureType !== 'container_service' || !infra.serviceId) {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
return await buildTerraformEnvForService(infra.serviceId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build the `TF_VAR_*` env-var record for a container service
|
|
42
|
+
* directly. Caller is responsible for already knowing the service
|
|
43
|
+
* ID (e.g. via `planDeployment`).
|
|
44
|
+
*/
|
|
45
|
+
export async function buildTerraformEnvForService(
|
|
46
|
+
serviceId: string,
|
|
47
|
+
): Promise<Record<string, string>> {
|
|
48
|
+
const service = await getContainerService(serviceId);
|
|
49
|
+
if (!service) return {};
|
|
50
|
+
|
|
51
|
+
const credentials = await getServiceCredentials(serviceId);
|
|
52
|
+
|
|
53
|
+
const env: Record<string, string> = {};
|
|
54
|
+
if (service.providerName === 'digitalocean' && 'api_token' in credentials) {
|
|
55
|
+
env.TF_VAR_digitalocean_token = credentials.api_token as string;
|
|
56
|
+
} else if (service.providerName === 'proxmox' && 'api_url' in credentials) {
|
|
57
|
+
env.TF_VAR_proxmox_api_url = credentials.api_url as string;
|
|
58
|
+
env.TF_VAR_proxmox_token_id = credentials.api_token_id as string;
|
|
59
|
+
env.TF_VAR_proxmox_token_secret = credentials.api_token_secret as string;
|
|
60
|
+
}
|
|
61
|
+
return env;
|
|
62
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ModuleManifest } from '../../manifest/schema';
|
|
3
|
+
import {
|
|
4
|
+
DependencyCycleError,
|
|
5
|
+
buildModuleGraph,
|
|
6
|
+
levelsOf,
|
|
7
|
+
topologicalOrder,
|
|
8
|
+
transitiveConsumers,
|
|
9
|
+
} from './dep-graph';
|
|
10
|
+
|
|
11
|
+
function makeManifest(
|
|
12
|
+
id: string,
|
|
13
|
+
opts: {
|
|
14
|
+
provides?: string[];
|
|
15
|
+
requires?: string[];
|
|
16
|
+
optional?: string[];
|
|
17
|
+
} = {},
|
|
18
|
+
): ModuleManifest {
|
|
19
|
+
return {
|
|
20
|
+
id,
|
|
21
|
+
name: id,
|
|
22
|
+
version: '1.0.0',
|
|
23
|
+
celilo_contract: '1.0',
|
|
24
|
+
provides: opts.provides
|
|
25
|
+
? {
|
|
26
|
+
capabilities: opts.provides.map((name) => ({
|
|
27
|
+
name,
|
|
28
|
+
version: '1.0.0',
|
|
29
|
+
data: {},
|
|
30
|
+
functions: [],
|
|
31
|
+
})),
|
|
32
|
+
}
|
|
33
|
+
: { capabilities: [] },
|
|
34
|
+
requires: opts.requires
|
|
35
|
+
? { capabilities: opts.requires.map((name) => ({ name, version: '1.0.0' })) }
|
|
36
|
+
: { capabilities: [] },
|
|
37
|
+
optional: opts.optional
|
|
38
|
+
? { capabilities: opts.optional.map((name) => ({ name, version: '1.0.0' })) }
|
|
39
|
+
: undefined,
|
|
40
|
+
} as unknown as ModuleManifest;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('buildModuleGraph', () => {
|
|
44
|
+
test('empty input → empty graph', () => {
|
|
45
|
+
const g = buildModuleGraph([]);
|
|
46
|
+
expect(g.nodes.size).toBe(0);
|
|
47
|
+
expect(g.edges.size).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('module with no deps → node with no edges', () => {
|
|
51
|
+
const g = buildModuleGraph([makeManifest('iptables', { provides: ['firewall'] })]);
|
|
52
|
+
expect(g.nodes.size).toBe(1);
|
|
53
|
+
expect(g.edges.get('iptables')?.size).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('builds consumer → provider edge', () => {
|
|
57
|
+
const g = buildModuleGraph([
|
|
58
|
+
makeManifest('iptables', { provides: ['firewall'] }),
|
|
59
|
+
makeManifest('caddy', { requires: ['firewall'] }),
|
|
60
|
+
]);
|
|
61
|
+
expect([...(g.edges.get('caddy') ?? [])]).toEqual(['iptables']);
|
|
62
|
+
expect([...(g.reverseEdges.get('iptables') ?? [])]).toEqual(['caddy']);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('optional capabilities also create edges', () => {
|
|
66
|
+
const g = buildModuleGraph([
|
|
67
|
+
makeManifest('namecheap', { provides: ['dns_registrar'] }),
|
|
68
|
+
makeManifest('caddy', { optional: ['dns_registrar'] }),
|
|
69
|
+
]);
|
|
70
|
+
expect([...(g.edges.get('caddy') ?? [])]).toEqual(['namecheap']);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('skips edges to missing providers (no provider for declared capability)', () => {
|
|
74
|
+
const g = buildModuleGraph([makeManifest('caddy', { requires: ['nonexistent'] })]);
|
|
75
|
+
expect(g.edges.get('caddy')?.size).toBe(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('skips self-edges when a module consumes its own capability', () => {
|
|
79
|
+
const g = buildModuleGraph([makeManifest('weird', { provides: ['x'], requires: ['x'] })]);
|
|
80
|
+
expect(g.edges.get('weird')?.size).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('first declared provider wins when multiple modules provide the same capability', () => {
|
|
84
|
+
const g = buildModuleGraph([
|
|
85
|
+
makeManifest('a', { provides: ['firewall'] }),
|
|
86
|
+
makeManifest('b', { provides: ['firewall'] }),
|
|
87
|
+
makeManifest('caddy', { requires: ['firewall'] }),
|
|
88
|
+
]);
|
|
89
|
+
expect([...(g.edges.get('caddy') ?? [])]).toEqual(['a']);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('topologicalOrder', () => {
|
|
94
|
+
test('empty graph yields empty list', () => {
|
|
95
|
+
expect(topologicalOrder(buildModuleGraph([]))).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('places provider before consumer', () => {
|
|
99
|
+
const g = buildModuleGraph([
|
|
100
|
+
makeManifest('caddy', { requires: ['firewall'] }),
|
|
101
|
+
makeManifest('iptables', { provides: ['firewall'] }),
|
|
102
|
+
]);
|
|
103
|
+
const order = topologicalOrder(g);
|
|
104
|
+
expect(order.indexOf('iptables')).toBeLessThan(order.indexOf('caddy'));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('respects multi-level chain', () => {
|
|
108
|
+
const g = buildModuleGraph([
|
|
109
|
+
makeManifest('namecheap', { provides: ['dns_registrar'] }),
|
|
110
|
+
makeManifest('caddy', { requires: ['firewall', 'dns_registrar'], provides: ['public_web'] }),
|
|
111
|
+
makeManifest('iptables', { provides: ['firewall'] }),
|
|
112
|
+
makeManifest('lunacycle', { requires: ['public_web'] }),
|
|
113
|
+
]);
|
|
114
|
+
const order = topologicalOrder(g);
|
|
115
|
+
expect(order.indexOf('iptables')).toBeLessThan(order.indexOf('caddy'));
|
|
116
|
+
expect(order.indexOf('namecheap')).toBeLessThan(order.indexOf('caddy'));
|
|
117
|
+
expect(order.indexOf('caddy')).toBeLessThan(order.indexOf('lunacycle'));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('alphabetical tiebreak among siblings', () => {
|
|
121
|
+
const g = buildModuleGraph([
|
|
122
|
+
makeManifest('zebra'),
|
|
123
|
+
makeManifest('alpha'),
|
|
124
|
+
makeManifest('mango'),
|
|
125
|
+
]);
|
|
126
|
+
expect(topologicalOrder(g)).toEqual(['alpha', 'mango', 'zebra']);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('throws DependencyCycleError on a 2-cycle', () => {
|
|
130
|
+
const g = buildModuleGraph([
|
|
131
|
+
makeManifest('a', { provides: ['cap_a'], requires: ['cap_b'] }),
|
|
132
|
+
makeManifest('b', { provides: ['cap_b'], requires: ['cap_a'] }),
|
|
133
|
+
]);
|
|
134
|
+
expect(() => topologicalOrder(g)).toThrow(DependencyCycleError);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('cycle error contains the cycle path', () => {
|
|
138
|
+
const g = buildModuleGraph([
|
|
139
|
+
makeManifest('a', { provides: ['cap_a'], requires: ['cap_b'] }),
|
|
140
|
+
makeManifest('b', { provides: ['cap_b'], requires: ['cap_a'] }),
|
|
141
|
+
]);
|
|
142
|
+
try {
|
|
143
|
+
topologicalOrder(g);
|
|
144
|
+
throw new Error('should have thrown');
|
|
145
|
+
} catch (err) {
|
|
146
|
+
expect(err).toBeInstanceOf(DependencyCycleError);
|
|
147
|
+
expect((err as DependencyCycleError).cycle.length).toBeGreaterThan(0);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('transitiveConsumers', () => {
|
|
153
|
+
test('returns all downstream modules', () => {
|
|
154
|
+
const g = buildModuleGraph([
|
|
155
|
+
makeManifest('iptables', { provides: ['firewall'] }),
|
|
156
|
+
makeManifest('caddy', { requires: ['firewall'], provides: ['public_web'] }),
|
|
157
|
+
makeManifest('lunacycle', { requires: ['public_web'] }),
|
|
158
|
+
makeManifest('celilo-website', { requires: ['public_web'] }),
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
expect(transitiveConsumers(g, 'iptables').sort()).toEqual([
|
|
162
|
+
'caddy',
|
|
163
|
+
'celilo-website',
|
|
164
|
+
'lunacycle',
|
|
165
|
+
]);
|
|
166
|
+
expect(transitiveConsumers(g, 'caddy').sort()).toEqual(['celilo-website', 'lunacycle']);
|
|
167
|
+
expect(transitiveConsumers(g, 'lunacycle')).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('returns empty for a module with no consumers', () => {
|
|
171
|
+
const g = buildModuleGraph([makeManifest('iptables', { provides: ['firewall'] })]);
|
|
172
|
+
expect(transitiveConsumers(g, 'iptables')).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('returns empty for a module not in the graph', () => {
|
|
176
|
+
const g = buildModuleGraph([makeManifest('iptables', { provides: ['firewall'] })]);
|
|
177
|
+
expect(transitiveConsumers(g, 'nonexistent')).toEqual([]);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('levelsOf', () => {
|
|
182
|
+
test('flat dependency tree → all level 0', () => {
|
|
183
|
+
const g = buildModuleGraph([makeManifest('a'), makeManifest('b'), makeManifest('c')]);
|
|
184
|
+
const levels = levelsOf(g);
|
|
185
|
+
expect(levels[0].sort()).toEqual(['a', 'b', 'c']);
|
|
186
|
+
expect(levels.length).toBe(1);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('linear chain assigns level per depth', () => {
|
|
190
|
+
const g = buildModuleGraph([
|
|
191
|
+
makeManifest('iptables', { provides: ['firewall'] }),
|
|
192
|
+
makeManifest('caddy', { requires: ['firewall'], provides: ['public_web'] }),
|
|
193
|
+
makeManifest('lunacycle', { requires: ['public_web'] }),
|
|
194
|
+
]);
|
|
195
|
+
const levels = levelsOf(g);
|
|
196
|
+
expect(levels[0]).toEqual(['iptables']);
|
|
197
|
+
expect(levels[1]).toEqual(['caddy']);
|
|
198
|
+
expect(levels[2]).toEqual(['lunacycle']);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('module depending on multiple providers gets max(deps)+1', () => {
|
|
202
|
+
const g = buildModuleGraph([
|
|
203
|
+
makeManifest('iptables', { provides: ['firewall'] }),
|
|
204
|
+
makeManifest('namecheap', { provides: ['dns_registrar'] }),
|
|
205
|
+
makeManifest('caddy', { requires: ['firewall'], provides: ['public_web'] }),
|
|
206
|
+
makeManifest('mixed', { requires: ['public_web', 'dns_registrar'] }),
|
|
207
|
+
]);
|
|
208
|
+
const levels = levelsOf(g);
|
|
209
|
+
// iptables, namecheap → 0; caddy → 1; mixed → 2 (max of caddy:1, namecheap:0)
|
|
210
|
+
expect(levels[0].sort()).toEqual(['iptables', 'namecheap']);
|
|
211
|
+
expect(levels[1]).toEqual(['caddy']);
|
|
212
|
+
expect(levels[2]).toEqual(['mixed']);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module dependency graph.
|
|
3
|
+
*
|
|
4
|
+
* Builds a directed graph from a set of module manifests where an edge
|
|
5
|
+
* `consumer → provider` exists iff the consumer's `requires` or
|
|
6
|
+
* `optional` capabilities list a name that the provider declares in
|
|
7
|
+
* `provides`. The graph is used by both `system audit` (to render the
|
|
8
|
+
* dependency tree) and `system update` (to walk in topological order
|
|
9
|
+
* so providers upgrade before consumers).
|
|
10
|
+
*
|
|
11
|
+
* Pure data structures and pure operations — no I/O, no logging.
|
|
12
|
+
* Used by tests directly.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ModuleManifest } from '../../manifest/schema';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The id used to identify a module in the graph. Always equals
|
|
19
|
+
* `manifest.id` so callers don't need a separate keyspace.
|
|
20
|
+
*/
|
|
21
|
+
export type ModuleId = string;
|
|
22
|
+
|
|
23
|
+
export interface ModuleNode {
|
|
24
|
+
id: ModuleId;
|
|
25
|
+
manifest: ModuleManifest;
|
|
26
|
+
/**
|
|
27
|
+
* Capability names this module provides (the keys in
|
|
28
|
+
* `manifest.provides.capabilities[].name`). Cached for graph
|
|
29
|
+
* construction.
|
|
30
|
+
*/
|
|
31
|
+
provides: string[];
|
|
32
|
+
/**
|
|
33
|
+
* Required + optional capability names. Optional reqs participate
|
|
34
|
+
* in the graph the same way required ones do — they shape the
|
|
35
|
+
* upgrade order — but they don't block deploy if absent.
|
|
36
|
+
*/
|
|
37
|
+
consumes: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ModuleGraph {
|
|
41
|
+
nodes: Map<ModuleId, ModuleNode>;
|
|
42
|
+
/** consumer → providers it depends on */
|
|
43
|
+
edges: Map<ModuleId, Set<ModuleId>>;
|
|
44
|
+
/** provider → consumers that depend on it (reverse index) */
|
|
45
|
+
reverseEdges: Map<ModuleId, Set<ModuleId>>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Internal: build an index of capability name → providing module id. */
|
|
49
|
+
function indexProviders(modules: ModuleNode[]): Map<string, ModuleId> {
|
|
50
|
+
const index = new Map<string, ModuleId>();
|
|
51
|
+
for (const m of modules) {
|
|
52
|
+
for (const cap of m.provides) {
|
|
53
|
+
// First provider wins. The capability-loader's chain logic handles
|
|
54
|
+
// multi-provider scenarios at deploy time; for graph-walking
|
|
55
|
+
// purposes one edge per capability is enough.
|
|
56
|
+
if (!index.has(cap)) index.set(cap, m.id);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return index;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function buildModuleGraph(manifests: ModuleManifest[]): ModuleGraph {
|
|
63
|
+
const nodes = new Map<ModuleId, ModuleNode>();
|
|
64
|
+
for (const m of manifests) {
|
|
65
|
+
const provides = (m.provides?.capabilities ?? []).map((c) => c.name);
|
|
66
|
+
const required = (m.requires?.capabilities ?? []).map((c) => c.name);
|
|
67
|
+
const optional = (m.optional?.capabilities ?? []).map((c) => c.name);
|
|
68
|
+
nodes.set(m.id, {
|
|
69
|
+
id: m.id,
|
|
70
|
+
manifest: m,
|
|
71
|
+
provides,
|
|
72
|
+
consumes: [...required, ...optional],
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const providerIndex = indexProviders([...nodes.values()]);
|
|
77
|
+
const edges = new Map<ModuleId, Set<ModuleId>>();
|
|
78
|
+
const reverseEdges = new Map<ModuleId, Set<ModuleId>>();
|
|
79
|
+
|
|
80
|
+
for (const node of nodes.values()) {
|
|
81
|
+
edges.set(node.id, new Set());
|
|
82
|
+
reverseEdges.set(node.id, reverseEdges.get(node.id) ?? new Set());
|
|
83
|
+
|
|
84
|
+
for (const cap of node.consumes) {
|
|
85
|
+
const providerId = providerIndex.get(cap);
|
|
86
|
+
// Skip self-edges (a module that consumes its own capability —
|
|
87
|
+
// unusual but legal) and missing providers (deploy preflight
|
|
88
|
+
// handles those; graph just routes around them).
|
|
89
|
+
if (!providerId || providerId === node.id) continue;
|
|
90
|
+
|
|
91
|
+
edges.get(node.id)?.add(providerId);
|
|
92
|
+
const rev = reverseEdges.get(providerId) ?? new Set();
|
|
93
|
+
rev.add(node.id);
|
|
94
|
+
reverseEdges.set(providerId, rev);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { nodes, edges, reverseEdges };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class DependencyCycleError extends Error {
|
|
102
|
+
readonly cycle: readonly ModuleId[];
|
|
103
|
+
constructor(cycle: readonly ModuleId[]) {
|
|
104
|
+
super(`Dependency cycle detected: ${cycle.join(' → ')} → ${cycle[0]}`);
|
|
105
|
+
this.name = 'DependencyCycleError';
|
|
106
|
+
this.cycle = cycle;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Return module ids in dependency-safe order: every provider precedes
|
|
112
|
+
* every consumer. Throws `DependencyCycleError` if the graph contains
|
|
113
|
+
* a cycle (manifest declares mutual dependency, which is a bug in
|
|
114
|
+
* the manifests).
|
|
115
|
+
*
|
|
116
|
+
* Tie-breaks alphabetically so the order is deterministic across runs.
|
|
117
|
+
*/
|
|
118
|
+
export function topologicalOrder(graph: ModuleGraph): ModuleId[] {
|
|
119
|
+
const order: ModuleId[] = [];
|
|
120
|
+
const visited = new Set<ModuleId>();
|
|
121
|
+
const visiting = new Set<ModuleId>();
|
|
122
|
+
|
|
123
|
+
function visit(id: ModuleId, path: ModuleId[]): void {
|
|
124
|
+
if (visited.has(id)) return;
|
|
125
|
+
if (visiting.has(id)) {
|
|
126
|
+
const cycleStart = path.indexOf(id);
|
|
127
|
+
throw new DependencyCycleError(path.slice(cycleStart));
|
|
128
|
+
}
|
|
129
|
+
visiting.add(id);
|
|
130
|
+
|
|
131
|
+
// Visit providers first (deterministic order).
|
|
132
|
+
const providers = [...(graph.edges.get(id) ?? [])].sort();
|
|
133
|
+
for (const dep of providers) {
|
|
134
|
+
visit(dep, [...path, id]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
visiting.delete(id);
|
|
138
|
+
visited.add(id);
|
|
139
|
+
order.push(id);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Iterate in alphabetical order so callers without explicit deps
|
|
143
|
+
// get a stable order.
|
|
144
|
+
const ids = [...graph.nodes.keys()].sort();
|
|
145
|
+
for (const id of ids) {
|
|
146
|
+
visit(id, []);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return order;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Return all consumers (transitively) of `moduleId`. Used during update
|
|
154
|
+
* to find the subtree that should be skipped when a provider's upgrade
|
|
155
|
+
* fails (subject to the version-aware short-circuit in D3).
|
|
156
|
+
*/
|
|
157
|
+
export function transitiveConsumers(graph: ModuleGraph, moduleId: ModuleId): ModuleId[] {
|
|
158
|
+
const result: ModuleId[] = [];
|
|
159
|
+
const seen = new Set<ModuleId>([moduleId]);
|
|
160
|
+
const stack = [...(graph.reverseEdges.get(moduleId) ?? [])];
|
|
161
|
+
|
|
162
|
+
while (stack.length > 0) {
|
|
163
|
+
const next = stack.pop();
|
|
164
|
+
if (next === undefined || seen.has(next)) continue;
|
|
165
|
+
seen.add(next);
|
|
166
|
+
result.push(next);
|
|
167
|
+
const consumers = graph.reverseEdges.get(next);
|
|
168
|
+
if (consumers) stack.push(...consumers);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return result.sort();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Group nodes into "levels" where level 0 has no dependencies inside
|
|
176
|
+
* the graph, level 1 depends only on level 0, etc. Useful for
|
|
177
|
+
* rendering the audit / dry-run output as an indented tree.
|
|
178
|
+
*/
|
|
179
|
+
export function levelsOf(graph: ModuleGraph): ModuleId[][] {
|
|
180
|
+
const levelByModule = new Map<ModuleId, number>();
|
|
181
|
+
|
|
182
|
+
function levelOf(id: ModuleId, path: Set<ModuleId>): number {
|
|
183
|
+
const cached = levelByModule.get(id);
|
|
184
|
+
if (cached !== undefined) return cached;
|
|
185
|
+
if (path.has(id)) {
|
|
186
|
+
// Cycle — return 0 so the topological-order check surfaces a clearer error.
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
const deps = graph.edges.get(id);
|
|
190
|
+
if (!deps || deps.size === 0) {
|
|
191
|
+
levelByModule.set(id, 0);
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
const next = new Set(path);
|
|
195
|
+
next.add(id);
|
|
196
|
+
let max = 0;
|
|
197
|
+
for (const d of deps) {
|
|
198
|
+
max = Math.max(max, levelOf(d, next) + 1);
|
|
199
|
+
}
|
|
200
|
+
levelByModule.set(id, max);
|
|
201
|
+
return max;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const id of graph.nodes.keys()) {
|
|
205
|
+
levelOf(id, new Set());
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const levels: ModuleId[][] = [];
|
|
209
|
+
for (const [id, lvl] of levelByModule) {
|
|
210
|
+
while (levels.length <= lvl) levels.push([]);
|
|
211
|
+
levels[lvl].push(id);
|
|
212
|
+
}
|
|
213
|
+
for (const lvl of levels) lvl.sort();
|
|
214
|
+
return levels;
|
|
215
|
+
}
|