@celilo/cli 0.1.5 → 0.1.7
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 +3 -2
- package/src/ansible/inventory.ts +5 -1
- package/src/capabilities/public-web-helpers.test.ts +2 -2
- package/src/capabilities/public-web-publish.test.ts +34 -1
- 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-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.ts +26 -17
- package/src/hooks/capability-loader.ts +133 -73
- package/src/hooks/define-hook.test.ts +9 -1
- 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/module/import.ts +20 -12
- package/src/module/packaging/build.ts +85 -16
- 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-preflight.ts +45 -4
- package/src/services/deploy-terraform.ts +31 -24
- package/src/services/deploy-validation.ts +167 -23
- package/src/services/ensure-interview.test.ts +245 -0
- package/src/services/health-runner.ts +110 -38
- package/src/services/module-build.ts +11 -13
- package/src/services/module-deploy.ts +370 -59
- 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 +1 -1
- package/src/templates/generator.ts +42 -1
- 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.ts +49 -1
- package/src/variables/declarative-derivation.test.ts +306 -0
- package/src/variables/declarative-derivation.ts +4 -2
- package/src/variables/parser.test.ts +56 -1
- package/src/variables/parser.ts +47 -6
- package/src/variables/resolver.ts +27 -9
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System audit aggregator.
|
|
3
|
+
*
|
|
4
|
+
* Calls each drift-category check in parallel and assembles a
|
|
5
|
+
* `SystemAuditReport`. Each category check is dependency-injected so
|
|
6
|
+
* the aggregator can be tested in isolation and the CLI command can
|
|
7
|
+
* wire the real DB / registry / health-runner / terraform binary.
|
|
8
|
+
*
|
|
9
|
+
* No mutations are performed by `runAudit`. The single source of
|
|
10
|
+
* truth for what audit can detect is this file's `AuditDeps` shape.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { type BackupsAuditDeps, auditBackups } from './backups';
|
|
14
|
+
import { type CapabilityAbiAuditDeps, auditCapabilityAbi } from './capability-abi';
|
|
15
|
+
import { type CliVersionAuditDeps, auditCliVersion } from './cli-version';
|
|
16
|
+
import { type HealthAuditDeps, auditHealth } from './health';
|
|
17
|
+
import { type MachinesReachableAuditDeps, auditMachinesReachable } from './machines-reachable';
|
|
18
|
+
import { type ModuleConfigsAuditDeps, auditModuleConfigs } from './module-configs';
|
|
19
|
+
import { type ModuleVersionsAuditDeps, auditModuleVersions } from './module-versions';
|
|
20
|
+
import { type SchemaAuditDeps, auditSchema } from './schema';
|
|
21
|
+
import { type SecretsDecryptableAuditDeps, auditSecretsDecryptable } from './secrets-decryptable';
|
|
22
|
+
import {
|
|
23
|
+
type ServicesCredentialsAuditDeps,
|
|
24
|
+
auditServicesCredentials,
|
|
25
|
+
} from './services-credentials';
|
|
26
|
+
import { type ServicesReachableAuditDeps, auditServicesReachable } from './services-reachable';
|
|
27
|
+
import { type TerraformPlanAuditDeps, auditTerraformPlan } from './terraform-plan';
|
|
28
|
+
import {
|
|
29
|
+
type DriftCategory,
|
|
30
|
+
type DriftFinding,
|
|
31
|
+
type SystemAuditReport,
|
|
32
|
+
computeVerdict,
|
|
33
|
+
} from './types';
|
|
34
|
+
import {
|
|
35
|
+
type UnconfiguredModulesAuditDeps,
|
|
36
|
+
auditUnconfiguredModules,
|
|
37
|
+
} from './unconfigured-modules';
|
|
38
|
+
import { type UndeployedModulesAuditDeps, auditUndeployedModules } from './undeployed-modules';
|
|
39
|
+
|
|
40
|
+
export interface AuditDeps {
|
|
41
|
+
cliVersion: CliVersionAuditDeps;
|
|
42
|
+
schema: SchemaAuditDeps;
|
|
43
|
+
capabilityAbi: CapabilityAbiAuditDeps;
|
|
44
|
+
terraformPlan: TerraformPlanAuditDeps;
|
|
45
|
+
moduleVersions: ModuleVersionsAuditDeps;
|
|
46
|
+
moduleConfigs: ModuleConfigsAuditDeps;
|
|
47
|
+
health: HealthAuditDeps;
|
|
48
|
+
backups: BackupsAuditDeps;
|
|
49
|
+
undeployedModules: UndeployedModulesAuditDeps;
|
|
50
|
+
unconfiguredModules: UnconfiguredModulesAuditDeps;
|
|
51
|
+
servicesCredentials: ServicesCredentialsAuditDeps;
|
|
52
|
+
secretsDecryptable: SecretsDecryptableAuditDeps;
|
|
53
|
+
servicesReachable: ServicesReachableAuditDeps;
|
|
54
|
+
machinesReachable: MachinesReachableAuditDeps;
|
|
55
|
+
/** Defaults to `Date.now()`-based ISO string. */
|
|
56
|
+
now?: () => Date;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Per-category lifecycle event emitted by `runAudit`. Lets a UI
|
|
61
|
+
* (the audit TUI) display per-category fuel-gauges instead of one
|
|
62
|
+
* opaque spinner. `start` fires when the category's promise enters
|
|
63
|
+
* the parallel pool; `end` fires when its promise resolves.
|
|
64
|
+
*/
|
|
65
|
+
export interface AuditCategoryEvent {
|
|
66
|
+
category: DriftCategory;
|
|
67
|
+
phase: 'start' | 'end';
|
|
68
|
+
/** Findings array — present on `phase: 'end'` only. */
|
|
69
|
+
findings?: DriftFinding[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function runAudit(
|
|
73
|
+
deps: AuditDeps,
|
|
74
|
+
onProgress?: (event: AuditCategoryEvent) => void,
|
|
75
|
+
): Promise<SystemAuditReport> {
|
|
76
|
+
// Wrap each category's promise so the caller sees per-category
|
|
77
|
+
// start/end events. `start` fires synchronously when this function
|
|
78
|
+
// schedules the category; in practice all 14 fire roughly at once
|
|
79
|
+
// since Promise.all spawns them in parallel.
|
|
80
|
+
const wrap = (category: DriftCategory, p: Promise<DriftFinding[]>): Promise<DriftFinding[]> => {
|
|
81
|
+
onProgress?.({ category, phase: 'start' });
|
|
82
|
+
return p.then((findings) => {
|
|
83
|
+
onProgress?.({ category, phase: 'end', findings });
|
|
84
|
+
return findings;
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Run all categories in parallel; each returns its own array.
|
|
89
|
+
const groups = await Promise.all([
|
|
90
|
+
wrap('cli_version', auditCliVersion(deps.cliVersion)),
|
|
91
|
+
wrap('schema', auditSchema(deps.schema)),
|
|
92
|
+
wrap('capability_abi', auditCapabilityAbi(deps.capabilityAbi)),
|
|
93
|
+
wrap('terraform_plan', auditTerraformPlan(deps.terraformPlan)),
|
|
94
|
+
wrap('module_versions', auditModuleVersions(deps.moduleVersions)),
|
|
95
|
+
wrap('module_configs', auditModuleConfigs(deps.moduleConfigs)),
|
|
96
|
+
wrap('health', auditHealth(deps.health)),
|
|
97
|
+
wrap('backups', auditBackups(deps.backups)),
|
|
98
|
+
wrap('undeployed_modules', auditUndeployedModules(deps.undeployedModules)),
|
|
99
|
+
wrap('unconfigured_modules', auditUnconfiguredModules(deps.unconfiguredModules)),
|
|
100
|
+
wrap('services_credentials', auditServicesCredentials(deps.servicesCredentials)),
|
|
101
|
+
wrap('secrets_decryptable', auditSecretsDecryptable(deps.secretsDecryptable)),
|
|
102
|
+
wrap('services_reachable', auditServicesReachable(deps.servicesReachable)),
|
|
103
|
+
wrap('machines_reachable', auditMachinesReachable(deps.machinesReachable)),
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
const findings: DriftFinding[] = groups.flat();
|
|
107
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
version: 1,
|
|
111
|
+
verdict: computeVerdict(findings),
|
|
112
|
+
generatedAt: now.toISOString(),
|
|
113
|
+
findings,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Re-export types for callers.
|
|
118
|
+
export type { SystemAuditReport, DriftFinding, AuditVerdict, DriftCategory } from './types';
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { auditMachinesReachable } from './machines-reachable';
|
|
3
|
+
|
|
4
|
+
describe('auditMachinesReachable', () => {
|
|
5
|
+
test('no findings when every machine is reachable', async () => {
|
|
6
|
+
const result = await auditMachinesReachable({
|
|
7
|
+
results: [
|
|
8
|
+
{ id: 'm1', hostname: 'iot', ipAddress: '10.0.0.10', reachable: true },
|
|
9
|
+
{ id: 'm2', hostname: 'dns-ext', ipAddress: '203.0.113.5', reachable: true },
|
|
10
|
+
],
|
|
11
|
+
});
|
|
12
|
+
expect(result).toEqual([]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('per-machine drift finding for one unreachable host', async () => {
|
|
16
|
+
const result = await auditMachinesReachable({
|
|
17
|
+
results: [
|
|
18
|
+
{ id: 'm1', hostname: 'iot', ipAddress: '10.0.0.10', reachable: true },
|
|
19
|
+
{
|
|
20
|
+
id: 'm2',
|
|
21
|
+
hostname: 'dns-ext',
|
|
22
|
+
ipAddress: '203.0.113.5',
|
|
23
|
+
reachable: false,
|
|
24
|
+
message: 'Connection timed out',
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
});
|
|
28
|
+
expect(result).toHaveLength(1);
|
|
29
|
+
expect(result[0]).toMatchObject({
|
|
30
|
+
category: 'machines_reachable',
|
|
31
|
+
severity: 'drift',
|
|
32
|
+
code: 'machine_unreachable',
|
|
33
|
+
subject: 'm2',
|
|
34
|
+
actionable: false,
|
|
35
|
+
});
|
|
36
|
+
expect(result[0].message).toContain('dns-ext');
|
|
37
|
+
expect(result[0].details).toContain('Connection timed out');
|
|
38
|
+
expect(result[0].remediation).toContain('celilo machine remove dns-ext');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('collapses to "all_machines_unreachable" when every machine fails', async () => {
|
|
42
|
+
const result = await auditMachinesReachable({
|
|
43
|
+
results: [
|
|
44
|
+
{
|
|
45
|
+
id: 'm1',
|
|
46
|
+
hostname: 'iot',
|
|
47
|
+
ipAddress: '10.0.0.10',
|
|
48
|
+
reachable: false,
|
|
49
|
+
message: 'host down',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'm2',
|
|
53
|
+
hostname: 'dns-ext',
|
|
54
|
+
ipAddress: '203.0.113.5',
|
|
55
|
+
reachable: false,
|
|
56
|
+
message: 'host down',
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
expect(result).toHaveLength(1);
|
|
61
|
+
expect(result[0]).toMatchObject({
|
|
62
|
+
category: 'machines_reachable',
|
|
63
|
+
severity: 'drift',
|
|
64
|
+
code: 'all_machines_unreachable',
|
|
65
|
+
subject: 'system',
|
|
66
|
+
actionable: false,
|
|
67
|
+
});
|
|
68
|
+
expect(result[0].message).toContain('All 2 machines unreachable');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('does NOT collapse when only one machine is in the pool', async () => {
|
|
72
|
+
// A single machine failing is per-machine, not a system-wide signal.
|
|
73
|
+
const result = await auditMachinesReachable({
|
|
74
|
+
results: [
|
|
75
|
+
{
|
|
76
|
+
id: 'm1',
|
|
77
|
+
hostname: 'iot',
|
|
78
|
+
ipAddress: '10.0.0.10',
|
|
79
|
+
reachable: false,
|
|
80
|
+
message: 'down',
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
expect(result).toHaveLength(1);
|
|
85
|
+
expect(result[0].code).toBe('machine_unreachable');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machines-reachable check.
|
|
3
|
+
*
|
|
4
|
+
* For each machine in the pool, attempts a quick SSH probe (5s
|
|
5
|
+
* timeout, BatchMode=yes so it never hangs on a password prompt).
|
|
6
|
+
* Catches:
|
|
7
|
+
*
|
|
8
|
+
* - VPS terminated or paused.
|
|
9
|
+
* - Raspberry Pi powered off / unplugged.
|
|
10
|
+
* - SSH key rotated; old key no longer works.
|
|
11
|
+
* - Network partition (machine on a zone the celilo host can't
|
|
12
|
+
* reach right now).
|
|
13
|
+
*
|
|
14
|
+
* Severity is `drift` — these are operational, not architectural.
|
|
15
|
+
* The audit consumes pre-computed probe results so it stays unit-
|
|
16
|
+
* testable without a live SSH client.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { DriftFinding } from './types';
|
|
20
|
+
|
|
21
|
+
export interface MachineReachableResult {
|
|
22
|
+
/** Stable machine ID (UUID in the DB). */
|
|
23
|
+
id: string;
|
|
24
|
+
/** Hostname for the finding message. */
|
|
25
|
+
hostname: string;
|
|
26
|
+
ipAddress: string;
|
|
27
|
+
/** True if SSH probe succeeded. */
|
|
28
|
+
reachable: boolean;
|
|
29
|
+
/** Short description of failure when `!reachable`. */
|
|
30
|
+
message?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MachinesReachableAuditDeps {
|
|
34
|
+
results: MachineReachableResult[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function auditMachinesReachable(
|
|
38
|
+
deps: MachinesReachableAuditDeps,
|
|
39
|
+
): Promise<DriftFinding[]> {
|
|
40
|
+
const findings: DriftFinding[] = [];
|
|
41
|
+
|
|
42
|
+
// Collapse "all machines unreachable" → one finding pointing at the
|
|
43
|
+
// network/SSH-key/celilo-host root cause, not N identical lines.
|
|
44
|
+
const failed = deps.results.filter((r) => !r.reachable);
|
|
45
|
+
if (failed.length > 1 && failed.length === deps.results.length) {
|
|
46
|
+
findings.push({
|
|
47
|
+
category: 'machines_reachable',
|
|
48
|
+
severity: 'drift',
|
|
49
|
+
code: 'all_machines_unreachable',
|
|
50
|
+
message: `All ${failed.length} machines unreachable from this host`,
|
|
51
|
+
details:
|
|
52
|
+
'Every machine in the pool failed the SSH probe. Likely a\n' +
|
|
53
|
+
'network or SSH-key issue on the celilo host, not per-\n' +
|
|
54
|
+
'machine failure.',
|
|
55
|
+
remediation: [
|
|
56
|
+
'Check from this host:',
|
|
57
|
+
' - Network: can you ping the machines directly?',
|
|
58
|
+
' - SSH key: ls ~/.ssh/ (the key celilo uses to deploy)',
|
|
59
|
+
' - DNS: do machine hostnames resolve?',
|
|
60
|
+
].join('\n'),
|
|
61
|
+
actionable: false,
|
|
62
|
+
subject: 'system',
|
|
63
|
+
});
|
|
64
|
+
return findings;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const r of failed) {
|
|
68
|
+
findings.push({
|
|
69
|
+
category: 'machines_reachable',
|
|
70
|
+
severity: 'drift',
|
|
71
|
+
code: 'machine_unreachable',
|
|
72
|
+
message: `${r.hostname} (${r.ipAddress}): SSH unreachable`,
|
|
73
|
+
details: r.message,
|
|
74
|
+
remediation: [
|
|
75
|
+
'Verify the machine is powered on and reachable on the',
|
|
76
|
+
'network. If the SSH key was rotated, re-add the machine:',
|
|
77
|
+
` celilo machine remove ${r.hostname}`,
|
|
78
|
+
' celilo machine add # (re-run the wizard)',
|
|
79
|
+
].join('\n'),
|
|
80
|
+
// Multi-step / interactive; not a one-shot.
|
|
81
|
+
actionable: false,
|
|
82
|
+
subject: r.id,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return findings;
|
|
87
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ModuleManifest, VariableDeclare } from '../../manifest/schema';
|
|
3
|
+
import { type InstalledModuleConfig, auditModuleConfigs } from './module-configs';
|
|
4
|
+
|
|
5
|
+
function makeVariable(overrides: Partial<VariableDeclare>): VariableDeclare {
|
|
6
|
+
return {
|
|
7
|
+
name: 'foo',
|
|
8
|
+
type: 'string',
|
|
9
|
+
required: false,
|
|
10
|
+
source: 'user',
|
|
11
|
+
...overrides,
|
|
12
|
+
} as VariableDeclare;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makeModule(
|
|
16
|
+
id: string,
|
|
17
|
+
variables: VariableDeclare[],
|
|
18
|
+
configs: Record<string, unknown> = {},
|
|
19
|
+
): InstalledModuleConfig {
|
|
20
|
+
const manifest = {
|
|
21
|
+
id,
|
|
22
|
+
name: id,
|
|
23
|
+
version: '1.0.0',
|
|
24
|
+
celilo_contract: '1.0',
|
|
25
|
+
variables: { owns: variables, imports: [] },
|
|
26
|
+
} as unknown as ModuleManifest;
|
|
27
|
+
return { id, manifest, configs };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('auditModuleConfigs', () => {
|
|
31
|
+
test('no findings when every required variable has a value', async () => {
|
|
32
|
+
const result = await auditModuleConfigs({
|
|
33
|
+
modules: [
|
|
34
|
+
makeModule('caddy', [makeVariable({ name: 'hostname', required: true })], {
|
|
35
|
+
hostname: 'www',
|
|
36
|
+
}),
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
expect(result).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('blocked finding when required variable has no value and no default', async () => {
|
|
43
|
+
const result = await auditModuleConfigs({
|
|
44
|
+
modules: [makeModule('caddy', [makeVariable({ name: 'acme_email', required: true })], {})],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(result).toHaveLength(1);
|
|
48
|
+
expect(result[0]).toMatchObject({
|
|
49
|
+
severity: 'blocked',
|
|
50
|
+
code: 'module_config_required_unset',
|
|
51
|
+
subject: 'caddy',
|
|
52
|
+
});
|
|
53
|
+
expect(result[0].message).toContain('acme_email');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('no finding when required variable has a default (default applies)', async () => {
|
|
57
|
+
const result = await auditModuleConfigs({
|
|
58
|
+
modules: [
|
|
59
|
+
makeModule(
|
|
60
|
+
'caddy',
|
|
61
|
+
[makeVariable({ name: 'hostname', required: true, default: 'www' })],
|
|
62
|
+
{},
|
|
63
|
+
),
|
|
64
|
+
],
|
|
65
|
+
});
|
|
66
|
+
expect(result).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('no finding when optional variable is unset (no default needed)', async () => {
|
|
70
|
+
const result = await auditModuleConfigs({
|
|
71
|
+
modules: [makeModule('caddy', [makeVariable({ name: 'acme_ca', required: false })], {})],
|
|
72
|
+
});
|
|
73
|
+
expect(result).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('no finding when optional variable with default is unset', async () => {
|
|
77
|
+
const result = await auditModuleConfigs({
|
|
78
|
+
modules: [
|
|
79
|
+
makeModule(
|
|
80
|
+
'greenwave',
|
|
81
|
+
[makeVariable({ name: 'port_forwards', required: false, default: [] })],
|
|
82
|
+
{},
|
|
83
|
+
),
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
expect(result).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('skips non-user-sourced variables (infrastructure/capability/system)', async () => {
|
|
90
|
+
const result = await auditModuleConfigs({
|
|
91
|
+
modules: [
|
|
92
|
+
makeModule(
|
|
93
|
+
'caddy',
|
|
94
|
+
[
|
|
95
|
+
makeVariable({ name: 'target_node', required: true, source: 'infrastructure' }),
|
|
96
|
+
makeVariable({ name: 'primary_domain', required: true, source: 'capability' }),
|
|
97
|
+
makeVariable({ name: 'dns_primary', required: true, source: 'system' }),
|
|
98
|
+
],
|
|
99
|
+
{},
|
|
100
|
+
),
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
expect(result).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('treats empty string as unset', async () => {
|
|
107
|
+
const result = await auditModuleConfigs({
|
|
108
|
+
modules: [
|
|
109
|
+
makeModule('caddy', [makeVariable({ name: 'acme_email', required: true })], {
|
|
110
|
+
acme_email: '',
|
|
111
|
+
}),
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
expect(result).toHaveLength(1);
|
|
115
|
+
expect(result[0].severity).toBe('blocked');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('reports across multiple modules (only required-without-default)', async () => {
|
|
119
|
+
const result = await auditModuleConfigs({
|
|
120
|
+
modules: [
|
|
121
|
+
makeModule('caddy', [makeVariable({ name: 'a', required: true })], {}),
|
|
122
|
+
// iptables.b has a default, so even though it's required and unset,
|
|
123
|
+
// the default resolves the value — no finding.
|
|
124
|
+
makeModule('iptables', [makeVariable({ name: 'b', required: true, default: 'x' })], {}),
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(result).toHaveLength(1);
|
|
129
|
+
expect(result[0].subject).toBe('caddy');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module config drift check.
|
|
3
|
+
*
|
|
4
|
+
* For each installed module, walks `manifest.variables.owns` looking
|
|
5
|
+
* for variables that *must* have a value but don't. The check
|
|
6
|
+
* ignores `source != 'user'` variables — those are auto-derived from
|
|
7
|
+
* infrastructure / capability data and aren't supposed to be set by
|
|
8
|
+
* the user.
|
|
9
|
+
*
|
|
10
|
+
* Severity rules:
|
|
11
|
+
* - `required: true` AND no current value AND no `default:` → BLOCKED
|
|
12
|
+
* (the user has to set it; `system update` would otherwise fail at
|
|
13
|
+
* deploy time anyway).
|
|
14
|
+
* - All other unset cases → no finding. An optional variable with a
|
|
15
|
+
* default is by definition fine when unset (the default applies);
|
|
16
|
+
* a required variable with a default is also fine (the default
|
|
17
|
+
* resolves the value). Either case as drift would just be noise.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ModuleManifest, VariableDeclare } from '../../manifest/schema';
|
|
21
|
+
import type { DriftFinding } from './types';
|
|
22
|
+
|
|
23
|
+
export interface InstalledModuleConfig {
|
|
24
|
+
id: string;
|
|
25
|
+
manifest: ModuleManifest;
|
|
26
|
+
/** Map of config key → current value (string for primitives, parsed object for complex). */
|
|
27
|
+
configs: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ModuleConfigsAuditDeps {
|
|
31
|
+
modules: InstalledModuleConfig[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isUnset(value: unknown): boolean {
|
|
35
|
+
return value === undefined || value === null || value === '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ownVariableFindings(
|
|
39
|
+
moduleId: string,
|
|
40
|
+
variable: VariableDeclare,
|
|
41
|
+
currentValue: unknown,
|
|
42
|
+
): DriftFinding[] {
|
|
43
|
+
// Only audit user-sourced variables. Infrastructure/capability/system/
|
|
44
|
+
// terraform variables are auto-derived elsewhere; complaining about
|
|
45
|
+
// them being unset is noise.
|
|
46
|
+
if (variable.source !== 'user') return [];
|
|
47
|
+
if (!isUnset(currentValue)) return [];
|
|
48
|
+
|
|
49
|
+
const hasDefault = variable.default !== undefined && variable.default !== null;
|
|
50
|
+
|
|
51
|
+
if (variable.required && !hasDefault) {
|
|
52
|
+
return [
|
|
53
|
+
{
|
|
54
|
+
category: 'module_configs',
|
|
55
|
+
severity: 'blocked',
|
|
56
|
+
code: 'module_config_required_unset',
|
|
57
|
+
message: `${moduleId}: required config "${variable.name}" is not set`,
|
|
58
|
+
details: variable.description,
|
|
59
|
+
remediation: `celilo module config set ${moduleId} ${variable.name} <value>`,
|
|
60
|
+
actionable: true,
|
|
61
|
+
subject: moduleId,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function auditModuleConfigs(deps: ModuleConfigsAuditDeps): Promise<DriftFinding[]> {
|
|
70
|
+
const findings: DriftFinding[] = [];
|
|
71
|
+
|
|
72
|
+
for (const m of deps.modules) {
|
|
73
|
+
const owned = m.manifest.variables?.owns ?? [];
|
|
74
|
+
for (const variable of owned) {
|
|
75
|
+
findings.push(...ownVariableFindings(m.id, variable, m.configs[variable.name]));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return findings;
|
|
80
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { type ModuleVersionFetcher, auditModuleVersions } from './module-versions';
|
|
3
|
+
|
|
4
|
+
const installed = [
|
|
5
|
+
{ id: 'caddy', version: '1.2.0' },
|
|
6
|
+
{ id: 'iptables', version: '1.0.0' },
|
|
7
|
+
{ id: 'lunacycle', version: '1.0.0' },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
describe('auditModuleVersions', () => {
|
|
11
|
+
test('no findings when every installed module is at registry latest', async () => {
|
|
12
|
+
const fetcher: ModuleVersionFetcher = async (id) => ({
|
|
13
|
+
latest: installed.find((m) => m.id === id)?.version ?? '0.0.0',
|
|
14
|
+
});
|
|
15
|
+
const result = await auditModuleVersions({ installed, fetcher });
|
|
16
|
+
expect(result).toEqual([]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('drift finding per outdated module', async () => {
|
|
20
|
+
const fetcher: ModuleVersionFetcher = async (id) => {
|
|
21
|
+
if (id === 'caddy') return { latest: '1.3.0' };
|
|
22
|
+
if (id === 'iptables') return { latest: '1.0.0' };
|
|
23
|
+
if (id === 'lunacycle') return { latest: '1.1.0' };
|
|
24
|
+
throw new Error('unexpected id');
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const result = await auditModuleVersions({ installed, fetcher });
|
|
28
|
+
|
|
29
|
+
expect(result).toHaveLength(2);
|
|
30
|
+
expect(result.map((f) => f.subject).sort()).toEqual(['caddy', 'lunacycle']);
|
|
31
|
+
expect(result.find((f) => f.subject === 'caddy')?.message).toContain('1.2.0 → 1.3.0');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('appends "intervening releases" when intermediateCount > 1', async () => {
|
|
35
|
+
const fetcher: ModuleVersionFetcher = async () => ({
|
|
36
|
+
latest: '1.5.0',
|
|
37
|
+
intermediateCount: 3,
|
|
38
|
+
});
|
|
39
|
+
const result = await auditModuleVersions({
|
|
40
|
+
installed: [{ id: 'caddy', version: '1.2.0' }],
|
|
41
|
+
fetcher,
|
|
42
|
+
});
|
|
43
|
+
expect(result[0].message).toContain('3 intervening releases');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('renders release.json messages as details when supplied', async () => {
|
|
47
|
+
const fetcher: ModuleVersionFetcher = async () => ({
|
|
48
|
+
latest: '1.5.0',
|
|
49
|
+
intermediateCount: 3,
|
|
50
|
+
intermediateMessages: [
|
|
51
|
+
{ version: '1.5.0', message: 'Add HTTP/3 support' },
|
|
52
|
+
{ version: '1.4.0', message: 'Fix DNS race' },
|
|
53
|
+
],
|
|
54
|
+
});
|
|
55
|
+
const result = await auditModuleVersions({
|
|
56
|
+
installed: [{ id: 'caddy', version: '1.2.0' }],
|
|
57
|
+
fetcher,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(result[0].details).toContain('Add HTTP/3 support');
|
|
61
|
+
expect(result[0].details).toContain('1.5.0');
|
|
62
|
+
expect(result[0].details).toContain('Fix DNS race');
|
|
63
|
+
expect(result[0].details).toContain('1.4.0');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('module not in registry produces explanatory finding', async () => {
|
|
67
|
+
const fetcher: ModuleVersionFetcher = async () => ({ latest: null });
|
|
68
|
+
const result = await auditModuleVersions({
|
|
69
|
+
installed: [{ id: 'private-module', version: '1.0.0' }],
|
|
70
|
+
fetcher,
|
|
71
|
+
});
|
|
72
|
+
expect(result).toHaveLength(1);
|
|
73
|
+
expect(result[0].code).toBe('module_not_in_registry');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('per-module fetcher error becomes a drift finding (does not abort)', async () => {
|
|
77
|
+
const fetcher: ModuleVersionFetcher = async (id) => {
|
|
78
|
+
if (id === 'iptables') throw new Error('socket hang up');
|
|
79
|
+
return { latest: installed.find((m) => m.id === id)?.version ?? '0.0.0' };
|
|
80
|
+
};
|
|
81
|
+
const result = await auditModuleVersions({ installed, fetcher });
|
|
82
|
+
|
|
83
|
+
expect(result).toHaveLength(1);
|
|
84
|
+
expect(result[0]).toMatchObject({
|
|
85
|
+
code: 'module_version_lookup_failed',
|
|
86
|
+
subject: 'iptables',
|
|
87
|
+
});
|
|
88
|
+
expect(result[0].message).toContain('socket hang up');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('local module ahead of registry produces no finding', async () => {
|
|
92
|
+
const fetcher: ModuleVersionFetcher = async () => ({ latest: '1.0.0' });
|
|
93
|
+
const result = await auditModuleVersions({
|
|
94
|
+
installed: [{ id: 'caddy', version: '1.2.0' }],
|
|
95
|
+
fetcher,
|
|
96
|
+
});
|
|
97
|
+
expect(result).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
});
|