@celilo/cli 0.1.5 → 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 +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,571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System audit command — reports drift across the system.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* celilo system audit # human-readable table
|
|
6
|
+
* celilo system audit --json # machine-readable for scripts
|
|
7
|
+
*
|
|
8
|
+
* Read-only. No mutations. Wires the per-category audit checks
|
|
9
|
+
* (`apps/celilo/src/services/audit/*`) to the live DB / registry /
|
|
10
|
+
* health-runner / terraform binary, then formats the resulting
|
|
11
|
+
* `SystemAuditReport` for stdout.
|
|
12
|
+
*
|
|
13
|
+
* The actual audit logic lives in services/audit/. This command is a
|
|
14
|
+
* thin adapter — per Rule 10.5, it's a "thin adapter": parse args,
|
|
15
|
+
* compose deps, call `runAudit`, format output, return.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execFile } from 'node:child_process';
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { promisify } from 'node:util';
|
|
23
|
+
import { eq, isNotNull } from 'drizzle-orm';
|
|
24
|
+
import { testDigitalOceanConnection } from '../../api-clients/digitalocean';
|
|
25
|
+
import { testProxmoxConnection } from '../../api-clients/proxmox';
|
|
26
|
+
import { getDb } from '../../db/client';
|
|
27
|
+
import {
|
|
28
|
+
backups,
|
|
29
|
+
capabilitySecrets,
|
|
30
|
+
moduleConfigs as moduleConfigsTbl,
|
|
31
|
+
modules,
|
|
32
|
+
secrets,
|
|
33
|
+
systemSecrets,
|
|
34
|
+
} from '../../db/schema';
|
|
35
|
+
import type { ModuleManifest } from '../../manifest/schema';
|
|
36
|
+
import { RegistryClient } from '../../registry/client';
|
|
37
|
+
import { decryptSecret } from '../../secrets/encryption';
|
|
38
|
+
import { getOrCreateMasterKey } from '../../secrets/master-key';
|
|
39
|
+
import { runAudit } from '../../services/audit';
|
|
40
|
+
import type { DriftFinding, SystemAuditReport } from '../../services/audit';
|
|
41
|
+
import {
|
|
42
|
+
type LatestCliVersionFetcher,
|
|
43
|
+
fetchLatestCliVersion,
|
|
44
|
+
} from '../../services/audit/cli-version';
|
|
45
|
+
import type { MachineReachableResult } from '../../services/audit/machines-reachable';
|
|
46
|
+
import type { ModuleVersionFetcher } from '../../services/audit/module-versions';
|
|
47
|
+
import { makeJournalReader, readAppliedMigrations } from '../../services/audit/schema';
|
|
48
|
+
import type { SecretCheckResult } from '../../services/audit/secrets-decryptable';
|
|
49
|
+
import type { ServiceCredentialsResult } from '../../services/audit/services-credentials';
|
|
50
|
+
import type { ServiceReachableResult } from '../../services/audit/services-reachable';
|
|
51
|
+
import type { TerraformPlanRunner } from '../../services/audit/terraform-plan';
|
|
52
|
+
import { getServiceCredentials, listContainerServices } from '../../services/container-service';
|
|
53
|
+
import { runAllHealthChecks } from '../../services/health-runner';
|
|
54
|
+
import { listMachines } from '../../services/machine-pool';
|
|
55
|
+
import { buildTerraformEnvForModule } from '../../services/terraform-env';
|
|
56
|
+
import { hasFlag } from '../parser';
|
|
57
|
+
import type { CommandResult } from '../types';
|
|
58
|
+
|
|
59
|
+
const execFileAsync = promisify(execFile);
|
|
60
|
+
|
|
61
|
+
/** Read the running CLI version from the bundled package.json. */
|
|
62
|
+
function readInstalledCliVersion(): string {
|
|
63
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
64
|
+
const candidates = [
|
|
65
|
+
join(here, '..', '..', '..', 'package.json'),
|
|
66
|
+
join(process.cwd(), 'package.json'),
|
|
67
|
+
];
|
|
68
|
+
for (const path of candidates) {
|
|
69
|
+
if (existsSync(path)) {
|
|
70
|
+
try {
|
|
71
|
+
const pkg = JSON.parse(readFileSync(path, 'utf-8')) as { version?: string };
|
|
72
|
+
if (pkg.version) return pkg.version;
|
|
73
|
+
} catch {
|
|
74
|
+
// try the next candidate
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return '0.0.0'; // unknown — caller treats as "ahead of latest"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function makeRegistryFetcher(client: RegistryClient): ModuleVersionFetcher {
|
|
82
|
+
return async (moduleId) => {
|
|
83
|
+
const entries = await client.getIndex(moduleId);
|
|
84
|
+
if (entries.length === 0) return { latest: null };
|
|
85
|
+
const latest = client.latestVersion(entries);
|
|
86
|
+
if (!latest) return { latest: null };
|
|
87
|
+
return { latest: latest.vers, intermediateCount: entries.length };
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const realTerraformPlan: TerraformPlanRunner = async (terraformDir, envVars) => {
|
|
92
|
+
try {
|
|
93
|
+
const result = await execFileAsync(
|
|
94
|
+
'terraform',
|
|
95
|
+
['plan', '-detailed-exitcode', '-no-color', '-input=false'],
|
|
96
|
+
{
|
|
97
|
+
cwd: terraformDir,
|
|
98
|
+
timeout: 120_000,
|
|
99
|
+
// Inherit current env, then layer module-specific TF_VAR_*
|
|
100
|
+
// credentials so terraform sees the same world `module deploy`
|
|
101
|
+
// would. Without this, modules with provider creds (Proxmox,
|
|
102
|
+
// DigitalOcean) fail immediately with "no value for required
|
|
103
|
+
// variable" — a false positive, not real drift.
|
|
104
|
+
env: { ...process.env, ...envVars },
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
return { exitCode: 0, stdout: result.stdout, stderr: result.stderr };
|
|
108
|
+
} catch (err) {
|
|
109
|
+
const e = err as { code?: number; stdout?: string; stderr?: string; message?: string };
|
|
110
|
+
return {
|
|
111
|
+
// detailed-exitcode: 0 = no diff, 1 = error, 2 = diff
|
|
112
|
+
exitCode: e.code ?? 1,
|
|
113
|
+
stdout: e.stdout ?? '',
|
|
114
|
+
stderr: e.stderr ?? e.message ?? 'terraform plan failed',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function findMigrationsFolderSafe(): string | null {
|
|
120
|
+
// Mirror db/client.ts:findMigrationsFolder, but return null instead
|
|
121
|
+
// of throwing so the audit gracefully reports "schema check skipped"
|
|
122
|
+
// rather than crashing.
|
|
123
|
+
try {
|
|
124
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
125
|
+
const candidates = [
|
|
126
|
+
join(here, '..', '..', '..', 'drizzle'),
|
|
127
|
+
join(process.cwd(), 'drizzle'),
|
|
128
|
+
join(process.cwd(), 'apps', 'celilo', 'drizzle'),
|
|
129
|
+
];
|
|
130
|
+
for (const c of candidates) {
|
|
131
|
+
if (existsSync(join(c, 'meta', '_journal.json'))) return c;
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Build the dependency object for `runAudit` from the live system.
|
|
141
|
+
*
|
|
142
|
+
* `onProgress` (when supplied) suppresses stdout-bound progress
|
|
143
|
+
* indicators (FuelGauge) inside the audit subroutines and routes
|
|
144
|
+
* progress messages to the caller instead — used by the TUI so
|
|
145
|
+
* audit progress appears inside the alt-screen render.
|
|
146
|
+
*/
|
|
147
|
+
async function buildAuditDeps(onProgress?: (msg: string) => void) {
|
|
148
|
+
const db = getDb();
|
|
149
|
+
|
|
150
|
+
const installed = db.select().from(modules).all();
|
|
151
|
+
const deployedModules = installed.filter((m) => ['INSTALLED', 'VERIFIED'].includes(m.state));
|
|
152
|
+
|
|
153
|
+
const registryClient = new RegistryClient();
|
|
154
|
+
|
|
155
|
+
// Most recent successful backup per module.
|
|
156
|
+
// The `backups` table is added via inline ALTER statements in db/client.ts
|
|
157
|
+
// for upgraded databases; a freshly-initialized DB created from the
|
|
158
|
+
// drizzle journal alone may not have it yet. Treat absence as "no
|
|
159
|
+
// backups recorded" rather than crashing the audit.
|
|
160
|
+
const latestBackupByModule = new Map<string, number>();
|
|
161
|
+
try {
|
|
162
|
+
const successfulBackups = db
|
|
163
|
+
.select()
|
|
164
|
+
.from(backups)
|
|
165
|
+
.where(eq(backups.status, 'completed'))
|
|
166
|
+
.all();
|
|
167
|
+
for (const b of successfulBackups) {
|
|
168
|
+
if (!b.moduleId || !b.completedAt) continue;
|
|
169
|
+
const ts = b.completedAt.getTime();
|
|
170
|
+
const prev = latestBackupByModule.get(b.moduleId);
|
|
171
|
+
if (prev === undefined || ts > prev) latestBackupByModule.set(b.moduleId, ts);
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
// backups table missing — leave map empty
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Build per-module config map for module-configs check.
|
|
178
|
+
const allConfigs = db
|
|
179
|
+
.select()
|
|
180
|
+
.from(moduleConfigsTbl)
|
|
181
|
+
.where(isNotNull(moduleConfigsTbl.moduleId))
|
|
182
|
+
.all();
|
|
183
|
+
const configsByModule = new Map<string, Record<string, unknown>>();
|
|
184
|
+
for (const c of allConfigs) {
|
|
185
|
+
const map = configsByModule.get(c.moduleId) ?? {};
|
|
186
|
+
map[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
|
|
187
|
+
configsByModule.set(c.moduleId, map);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const installedConfigs = deployedModules.map((m) => ({
|
|
191
|
+
id: m.id,
|
|
192
|
+
manifest: m.manifestData as ModuleManifest,
|
|
193
|
+
configs: configsByModule.get(m.id) ?? {},
|
|
194
|
+
}));
|
|
195
|
+
|
|
196
|
+
const installedBackupInfo = deployedModules.map((m) => ({
|
|
197
|
+
id: m.id,
|
|
198
|
+
manifest: m.manifestData as ModuleManifest,
|
|
199
|
+
lastSuccessfulBackupAt: latestBackupByModule.get(m.id) ?? null,
|
|
200
|
+
}));
|
|
201
|
+
|
|
202
|
+
// Compose per-module TF_VAR_* env vars in parallel — each call hits
|
|
203
|
+
// the secret store / DB so they're not free, but they're all
|
|
204
|
+
// independent. Empty record for machine-bound modules.
|
|
205
|
+
const terraformEnvByModule = new Map<string, Record<string, string>>();
|
|
206
|
+
await Promise.all(
|
|
207
|
+
deployedModules.map(async (m) => {
|
|
208
|
+
try {
|
|
209
|
+
terraformEnvByModule.set(m.id, await buildTerraformEnvForModule(m.id, db));
|
|
210
|
+
} catch {
|
|
211
|
+
// Credential lookup failed — leave empty; terraform plan will
|
|
212
|
+
// surface the genuine "missing var" error and the user can
|
|
213
|
+
// fix the underlying credential.
|
|
214
|
+
terraformEnvByModule.set(m.id, {});
|
|
215
|
+
}
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const terraformModules = deployedModules.map((m) => ({
|
|
220
|
+
id: m.id,
|
|
221
|
+
terraformDir: existsSync(join(m.sourcePath, 'generated', 'terraform'))
|
|
222
|
+
? join(m.sourcePath, 'generated', 'terraform')
|
|
223
|
+
: null,
|
|
224
|
+
envVars: terraformEnvByModule.get(m.id) ?? {},
|
|
225
|
+
}));
|
|
226
|
+
|
|
227
|
+
const healthResults = await runAllHealthChecks(db, { onProgress });
|
|
228
|
+
|
|
229
|
+
// Services-credentials: try decrypting each container service's
|
|
230
|
+
// credential envelope. Failures (missing master key, wrong
|
|
231
|
+
// provider shape, corrupt envelope) become BLOCKED audit findings.
|
|
232
|
+
const allServices = await listContainerServices();
|
|
233
|
+
const serviceCredResults: ServiceCredentialsResult[] = await Promise.all(
|
|
234
|
+
allServices.map(async (s) => {
|
|
235
|
+
try {
|
|
236
|
+
await getServiceCredentials(s.id);
|
|
237
|
+
return {
|
|
238
|
+
serviceId: s.serviceId,
|
|
239
|
+
name: s.name,
|
|
240
|
+
providerName: s.providerName,
|
|
241
|
+
error: null,
|
|
242
|
+
};
|
|
243
|
+
} catch (err) {
|
|
244
|
+
return {
|
|
245
|
+
serviceId: s.serviceId,
|
|
246
|
+
name: s.name,
|
|
247
|
+
providerName: s.providerName,
|
|
248
|
+
error: err instanceof Error ? err.message : String(err),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
// Secrets-decryptable: try decrypting every encrypted secret in
|
|
255
|
+
// the DB (modules / system / capabilities). One bad master key
|
|
256
|
+
// makes every entry fail; the audit collapses that case into a
|
|
257
|
+
// single "master key mismatch" finding.
|
|
258
|
+
const secretResults: SecretCheckResult[] = [];
|
|
259
|
+
try {
|
|
260
|
+
const masterKey = await getOrCreateMasterKey();
|
|
261
|
+
const tryDecrypt = (env: { encryptedValue: string; iv: string; authTag: string }):
|
|
262
|
+
| string
|
|
263
|
+
| null => {
|
|
264
|
+
try {
|
|
265
|
+
decryptSecret(env, masterKey);
|
|
266
|
+
return null;
|
|
267
|
+
} catch (err) {
|
|
268
|
+
return err instanceof Error ? err.message : String(err);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
for (const s of db.select().from(secrets).all()) {
|
|
272
|
+
secretResults.push({
|
|
273
|
+
scope: 'module',
|
|
274
|
+
subject: s.moduleId,
|
|
275
|
+
name: s.name,
|
|
276
|
+
error: tryDecrypt(s),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
for (const s of db.select().from(systemSecrets).all()) {
|
|
280
|
+
secretResults.push({
|
|
281
|
+
scope: 'system',
|
|
282
|
+
subject: 'system',
|
|
283
|
+
name: s.key,
|
|
284
|
+
error: tryDecrypt(s),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
for (const s of db.select().from(capabilitySecrets).all()) {
|
|
288
|
+
// Capability secrets can be metadata-only (encrypted fields
|
|
289
|
+
// null) — skip those, they have nothing to decrypt.
|
|
290
|
+
if (!s.encryptedValue || !s.iv || !s.authTag) continue;
|
|
291
|
+
secretResults.push({
|
|
292
|
+
scope: 'capability',
|
|
293
|
+
subject: `capability:${s.capabilityId}`,
|
|
294
|
+
name: s.name,
|
|
295
|
+
error: tryDecrypt({
|
|
296
|
+
encryptedValue: s.encryptedValue,
|
|
297
|
+
iv: s.iv,
|
|
298
|
+
authTag: s.authTag,
|
|
299
|
+
}),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
} catch (err) {
|
|
303
|
+
// Master key unavailable — surface as a single system-level finding.
|
|
304
|
+
secretResults.push({
|
|
305
|
+
scope: 'system',
|
|
306
|
+
subject: 'system',
|
|
307
|
+
name: '<master-key>',
|
|
308
|
+
error: err instanceof Error ? err.message : String(err),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Services-reachable: ping each container service's API in parallel
|
|
313
|
+
// (Proxmox /version, DigitalOcean /v2/account). Reuses the same
|
|
314
|
+
// probes the `service add` wizard runs. Services with bad
|
|
315
|
+
// credentials get reachable=true here so we don't double-report —
|
|
316
|
+
// the credentials audit already flagged them.
|
|
317
|
+
const serviceReachableResults: ServiceReachableResult[] = await Promise.all(
|
|
318
|
+
allServices.map(async (s): Promise<ServiceReachableResult> => {
|
|
319
|
+
try {
|
|
320
|
+
const creds = await getServiceCredentials(s.id);
|
|
321
|
+
let probe: { success: boolean; message?: string };
|
|
322
|
+
if (s.providerName === 'proxmox' && 'api_url' in creds) {
|
|
323
|
+
probe = await testProxmoxConnection(creds);
|
|
324
|
+
} else if (s.providerName === 'digitalocean' && 'api_token' in creds) {
|
|
325
|
+
probe = await testDigitalOceanConnection(creds);
|
|
326
|
+
} else {
|
|
327
|
+
return {
|
|
328
|
+
serviceId: s.serviceId,
|
|
329
|
+
name: s.name,
|
|
330
|
+
providerName: s.providerName,
|
|
331
|
+
reachable: true,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
serviceId: s.serviceId,
|
|
336
|
+
name: s.name,
|
|
337
|
+
providerName: s.providerName,
|
|
338
|
+
reachable: probe.success,
|
|
339
|
+
message: probe.success ? undefined : (probe.message ?? 'unreachable'),
|
|
340
|
+
};
|
|
341
|
+
} catch {
|
|
342
|
+
return {
|
|
343
|
+
serviceId: s.serviceId,
|
|
344
|
+
name: s.name,
|
|
345
|
+
providerName: s.providerName,
|
|
346
|
+
reachable: true,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
}),
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
// Machines-reachable: SSH probe each pool machine in parallel.
|
|
353
|
+
// BatchMode=yes prevents password prompt hangs; ConnectTimeout=5
|
|
354
|
+
// bounds wait on unresponsive hosts. The audit's collapse logic
|
|
355
|
+
// turns "all unreachable" into a single host/network finding.
|
|
356
|
+
const allMachines = await listMachines();
|
|
357
|
+
const machineReachableResults: MachineReachableResult[] = await Promise.all(
|
|
358
|
+
allMachines.map(async (m): Promise<MachineReachableResult> => {
|
|
359
|
+
try {
|
|
360
|
+
await execFileAsync(
|
|
361
|
+
'ssh',
|
|
362
|
+
[
|
|
363
|
+
'-o',
|
|
364
|
+
'BatchMode=yes',
|
|
365
|
+
'-o',
|
|
366
|
+
'ConnectTimeout=5',
|
|
367
|
+
'-o',
|
|
368
|
+
'StrictHostKeyChecking=no',
|
|
369
|
+
'-o',
|
|
370
|
+
'UserKnownHostsFile=/dev/null',
|
|
371
|
+
`${m.sshUser}@${m.ipAddress}`,
|
|
372
|
+
'true',
|
|
373
|
+
],
|
|
374
|
+
{ timeout: 8000 },
|
|
375
|
+
);
|
|
376
|
+
return { id: m.id, hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
|
|
377
|
+
} catch (err) {
|
|
378
|
+
const e = err as { stderr?: string; message?: string };
|
|
379
|
+
return {
|
|
380
|
+
id: m.id,
|
|
381
|
+
hostname: m.hostname,
|
|
382
|
+
ipAddress: m.ipAddress,
|
|
383
|
+
reachable: false,
|
|
384
|
+
message: (e.stderr || e.message || 'unknown error').slice(0, 200),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
const migrationsFolder = findMigrationsFolderSafe();
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
cliVersion: {
|
|
394
|
+
installedVersion: readInstalledCliVersion(),
|
|
395
|
+
fetcher: fetchLatestCliVersion satisfies LatestCliVersionFetcher,
|
|
396
|
+
},
|
|
397
|
+
schema: {
|
|
398
|
+
journal: migrationsFolder ? makeJournalReader(migrationsFolder) : () => null,
|
|
399
|
+
applied: readAppliedMigrations,
|
|
400
|
+
db,
|
|
401
|
+
},
|
|
402
|
+
capabilityAbi: {
|
|
403
|
+
modules: deployedModules.map((m) => ({
|
|
404
|
+
id: m.id,
|
|
405
|
+
manifest: m.manifestData as ModuleManifest,
|
|
406
|
+
})),
|
|
407
|
+
},
|
|
408
|
+
terraformPlan: {
|
|
409
|
+
modules: terraformModules,
|
|
410
|
+
run: realTerraformPlan,
|
|
411
|
+
},
|
|
412
|
+
moduleVersions: {
|
|
413
|
+
installed: deployedModules.map((m) => ({ id: m.id, version: m.version })),
|
|
414
|
+
fetcher: makeRegistryFetcher(registryClient),
|
|
415
|
+
},
|
|
416
|
+
moduleConfigs: { modules: installedConfigs },
|
|
417
|
+
health: { results: healthResults },
|
|
418
|
+
backups: { modules: installedBackupInfo },
|
|
419
|
+
undeployedModules: {
|
|
420
|
+
modules: installed.map((m) => ({ id: m.id, state: m.state })),
|
|
421
|
+
},
|
|
422
|
+
unconfiguredModules: {
|
|
423
|
+
modules: installed.map((m) => ({
|
|
424
|
+
id: m.id,
|
|
425
|
+
state: m.state,
|
|
426
|
+
configCount: configsByModule.get(m.id)
|
|
427
|
+
? Object.keys(configsByModule.get(m.id) ?? {}).length
|
|
428
|
+
: 0,
|
|
429
|
+
})),
|
|
430
|
+
},
|
|
431
|
+
servicesCredentials: { results: serviceCredResults },
|
|
432
|
+
secretsDecryptable: { results: secretResults },
|
|
433
|
+
servicesReachable: { results: serviceReachableResults },
|
|
434
|
+
machinesReachable: { results: machineReachableResults },
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const VERDICT_ICON: Record<string, string> = {
|
|
439
|
+
READY: '●',
|
|
440
|
+
DRIFT: '⚠',
|
|
441
|
+
BLOCKED: '✗',
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const SEVERITY_ICON: Record<string, string> = {
|
|
445
|
+
drift: '⚠',
|
|
446
|
+
blocked: '✗',
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
/** Format a `SystemAuditReport` for a human reader. */
|
|
450
|
+
export function formatReport(report: SystemAuditReport): string {
|
|
451
|
+
const lines: string[] = [];
|
|
452
|
+
lines.push('');
|
|
453
|
+
lines.push(
|
|
454
|
+
`${VERDICT_ICON[report.verdict] ?? '?'} ${report.verdict} (${report.findings.length} finding${report.findings.length === 1 ? '' : 's'})`,
|
|
455
|
+
);
|
|
456
|
+
lines.push(` generated at ${report.generatedAt}`);
|
|
457
|
+
|
|
458
|
+
if (report.findings.length === 0) {
|
|
459
|
+
lines.push('');
|
|
460
|
+
lines.push(' System is up to date. No drift detected.');
|
|
461
|
+
return lines.join('\n');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Group findings by severity, then by category.
|
|
465
|
+
const byCategory = new Map<string, DriftFinding[]>();
|
|
466
|
+
for (const f of report.findings) {
|
|
467
|
+
const key = `${f.severity}/${f.category}`;
|
|
468
|
+
const arr = byCategory.get(key) ?? [];
|
|
469
|
+
arr.push(f);
|
|
470
|
+
byCategory.set(key, arr);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Sort: blocked first, then by category name.
|
|
474
|
+
const keys = [...byCategory.keys()].sort((a, b) => {
|
|
475
|
+
const aBlocked = a.startsWith('blocked/');
|
|
476
|
+
const bBlocked = b.startsWith('blocked/');
|
|
477
|
+
if (aBlocked !== bBlocked) return aBlocked ? -1 : 1;
|
|
478
|
+
return a.localeCompare(b);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
for (const key of keys) {
|
|
482
|
+
const findings = byCategory.get(key) ?? [];
|
|
483
|
+
const [severity, category] = key.split('/');
|
|
484
|
+
lines.push('');
|
|
485
|
+
lines.push(
|
|
486
|
+
` ${SEVERITY_ICON[severity]} ${severity.toUpperCase()} • ${category} (${findings.length})`,
|
|
487
|
+
);
|
|
488
|
+
for (const f of findings) {
|
|
489
|
+
lines.push(` - ${f.message}`);
|
|
490
|
+
if (f.details) {
|
|
491
|
+
for (const detail of f.details.split('\n')) {
|
|
492
|
+
lines.push(` ${detail}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (f.remediation) {
|
|
496
|
+
lines.push(` → ${f.remediation}`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return lines.join('\n');
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export async function handleSystemAudit(
|
|
505
|
+
_args: string[],
|
|
506
|
+
flags: Record<string, string | boolean>,
|
|
507
|
+
): Promise<CommandResult> {
|
|
508
|
+
const json = hasFlag(flags, 'json');
|
|
509
|
+
const explicitTui = hasFlag(flags, 'tui');
|
|
510
|
+
const noTui = hasFlag(flags, 'no-tui');
|
|
511
|
+
|
|
512
|
+
// Default to the TUI when stdout is an interactive terminal AND
|
|
513
|
+
// the user hasn't asked for a non-interactive output mode. JSON
|
|
514
|
+
// and --no-tui both opt out; piped/redirected stdout falls back
|
|
515
|
+
// to the static text report so the audit stays scriptable.
|
|
516
|
+
const tui = !json && !noTui && (explicitTui || Boolean(process.stdout.isTTY));
|
|
517
|
+
|
|
518
|
+
if (tui) {
|
|
519
|
+
// Defer the audit to inside the TUI so progress messages render
|
|
520
|
+
// in the alt-screen rather than leaking to the user's scrollback.
|
|
521
|
+
const themeRaw = String(flags.theme ?? '').toLowerCase();
|
|
522
|
+
const theme = themeRaw === 'light' ? 'light' : themeRaw === 'dark' ? 'dark' : undefined;
|
|
523
|
+
const { renderAuditTui } = await import('../tui/audit-tui');
|
|
524
|
+
try {
|
|
525
|
+
await renderAuditTui(
|
|
526
|
+
async (onProgress, onCategory) => {
|
|
527
|
+
// Bracket the two long phases so the user sees something
|
|
528
|
+
// before health checks begin emitting per-module messages,
|
|
529
|
+
// and so the spinner caption keeps changing during the
|
|
530
|
+
// parallel-audit phase (which doesn't emit its own).
|
|
531
|
+
onProgress('Reading system state…');
|
|
532
|
+
const deps = await buildAuditDeps(onProgress);
|
|
533
|
+
onProgress('Analyzing drift across categories…');
|
|
534
|
+
return runAudit(deps, onCategory);
|
|
535
|
+
},
|
|
536
|
+
{ theme },
|
|
537
|
+
);
|
|
538
|
+
return { success: true, message: '' };
|
|
539
|
+
} catch (err) {
|
|
540
|
+
return {
|
|
541
|
+
success: false,
|
|
542
|
+
error: err instanceof Error ? err.message : String(err),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
let deps: Awaited<ReturnType<typeof buildAuditDeps>>;
|
|
548
|
+
try {
|
|
549
|
+
deps = await buildAuditDeps();
|
|
550
|
+
} catch (err) {
|
|
551
|
+
return {
|
|
552
|
+
success: false,
|
|
553
|
+
error: `audit failed to gather state: ${err instanceof Error ? err.message : String(err)}`,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const report = await runAudit(deps);
|
|
558
|
+
|
|
559
|
+
if (json) {
|
|
560
|
+
return {
|
|
561
|
+
success: true,
|
|
562
|
+
message: JSON.stringify(report, null, 2),
|
|
563
|
+
rawOutput: true,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return {
|
|
568
|
+
success: true,
|
|
569
|
+
message: formatReport(report),
|
|
570
|
+
};
|
|
571
|
+
}
|