@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,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module version drift check.
|
|
3
|
+
*
|
|
4
|
+
* For each installed module, compares the installed version to the
|
|
5
|
+
* latest non-yanked version in the registry. A newer published version
|
|
6
|
+
* produces a `drift` finding per module. Network/registry failures
|
|
7
|
+
* for individual modules are surfaced as findings (severity: drift)
|
|
8
|
+
* so the user knows their audit may be incomplete, but they don't
|
|
9
|
+
* block the run.
|
|
10
|
+
*
|
|
11
|
+
* `registryFetcher` is injectable so tests don't hit a real registry.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { compareSemver } from './cli-version';
|
|
15
|
+
import type { DriftFinding } from './types';
|
|
16
|
+
|
|
17
|
+
export interface InstalledModule {
|
|
18
|
+
id: string;
|
|
19
|
+
version: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RegistryVersionInfo {
|
|
23
|
+
latest: string | null;
|
|
24
|
+
/** Optional: count of intermediate releases between installed and latest, for the report. */
|
|
25
|
+
intermediateCount?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Optional: release.json `message` from each release between installed
|
|
28
|
+
* and latest, newest-first. Surfaced in the audit report so the user
|
|
29
|
+
* sees "what changed" without leaving the terminal.
|
|
30
|
+
*/
|
|
31
|
+
intermediateMessages?: Array<{ version: string; message: string }>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fetches the latest registry version for a module. Returns null in
|
|
36
|
+
* the `.latest` field if the module isn't in the registry; throws on
|
|
37
|
+
* a transport error so the caller can decide between "module not
|
|
38
|
+
* published" and "lookup failed".
|
|
39
|
+
*/
|
|
40
|
+
export type ModuleVersionFetcher = (moduleId: string) => Promise<RegistryVersionInfo>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Bucket-key for a rejection reason: a short string that identifies
|
|
44
|
+
* the class of failure. Used to detect "every module failed for the
|
|
45
|
+
* same reason" (e.g. registry unreachable) so we can collapse to one
|
|
46
|
+
* aggregate finding instead of N noisy ones.
|
|
47
|
+
*/
|
|
48
|
+
function errorBucketKey(reason: unknown): string {
|
|
49
|
+
if (reason instanceof Error) {
|
|
50
|
+
if (reason.name === 'TimeoutError') return 'timeout';
|
|
51
|
+
if (reason.message.includes('ENOTFOUND') || reason.message.includes('EAI_AGAIN')) return 'dns';
|
|
52
|
+
if (reason.message.includes('ECONNREFUSED')) return 'connection-refused';
|
|
53
|
+
if (reason.message.includes('fetch failed')) return 'fetch-failed';
|
|
54
|
+
return reason.name;
|
|
55
|
+
}
|
|
56
|
+
return String(reason).slice(0, 40);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ModuleVersionsAuditDeps {
|
|
60
|
+
installed: InstalledModule[];
|
|
61
|
+
fetcher: ModuleVersionFetcher;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function auditModuleVersions(deps: ModuleVersionsAuditDeps): Promise<DriftFinding[]> {
|
|
65
|
+
const findings: DriftFinding[] = [];
|
|
66
|
+
|
|
67
|
+
// Run lookups in parallel; isolate per-module failures.
|
|
68
|
+
const results = await Promise.allSettled(
|
|
69
|
+
deps.installed.map(async (m) => ({ module: m, info: await deps.fetcher(m.id) })),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// If every lookup failed with the same error class (e.g. the
|
|
73
|
+
// registry is unreachable), emit ONE aggregate finding instead of
|
|
74
|
+
// N identical per-module findings — that's the user-facing signal,
|
|
75
|
+
// not "module X is broken".
|
|
76
|
+
const rejected = results.filter((r) => r.status === 'rejected') as PromiseRejectedResult[];
|
|
77
|
+
if (rejected.length > 0 && rejected.length === results.length) {
|
|
78
|
+
const errorBuckets = new Map<string, number>();
|
|
79
|
+
for (const r of rejected) {
|
|
80
|
+
const key = errorBucketKey(r.reason);
|
|
81
|
+
errorBuckets.set(key, (errorBuckets.get(key) ?? 0) + 1);
|
|
82
|
+
}
|
|
83
|
+
if (errorBuckets.size === 1) {
|
|
84
|
+
const [reason] = errorBuckets.keys();
|
|
85
|
+
findings.push({
|
|
86
|
+
category: 'module_versions',
|
|
87
|
+
severity: 'drift',
|
|
88
|
+
code: 'registry_unreachable',
|
|
89
|
+
message: `Registry unreachable — ${rejected.length} module${rejected.length === 1 ? '' : 's'} unchecked (${reason})`,
|
|
90
|
+
remediation: 'Check network connectivity to the registry, then press R to re-audit.',
|
|
91
|
+
actionable: false,
|
|
92
|
+
subject: 'system',
|
|
93
|
+
});
|
|
94
|
+
return findings;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (let i = 0; i < results.length; i++) {
|
|
99
|
+
const r = results[i];
|
|
100
|
+
const installed = deps.installed[i];
|
|
101
|
+
|
|
102
|
+
if (r.status === 'rejected') {
|
|
103
|
+
findings.push({
|
|
104
|
+
category: 'module_versions',
|
|
105
|
+
severity: 'drift',
|
|
106
|
+
code: 'module_version_lookup_failed',
|
|
107
|
+
message: `${installed.id}: registry lookup failed (${String(r.reason).slice(0, 80)})`,
|
|
108
|
+
remediation: 'Check network connectivity to the registry, then press R to re-audit.',
|
|
109
|
+
actionable: false,
|
|
110
|
+
subject: installed.id,
|
|
111
|
+
});
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { module, info } = r.value;
|
|
116
|
+
if (info.latest === null) {
|
|
117
|
+
findings.push({
|
|
118
|
+
category: 'module_versions',
|
|
119
|
+
severity: 'drift',
|
|
120
|
+
code: 'module_not_in_registry',
|
|
121
|
+
message: `${module.id}@${module.version}: not found in registry (private/local-only build?)`,
|
|
122
|
+
subject: module.id,
|
|
123
|
+
});
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const cmp = compareSemver(module.version, info.latest);
|
|
128
|
+
if (cmp >= 0) continue; // up to date or ahead
|
|
129
|
+
|
|
130
|
+
const tail =
|
|
131
|
+
info.intermediateCount && info.intermediateCount > 1
|
|
132
|
+
? ` (${info.intermediateCount} intervening releases)`
|
|
133
|
+
: '';
|
|
134
|
+
|
|
135
|
+
// Format release messages as `details` lines if present.
|
|
136
|
+
const details =
|
|
137
|
+
info.intermediateMessages && info.intermediateMessages.length > 0
|
|
138
|
+
? info.intermediateMessages.map((m) => ` ↳ "${m.message}" (${m.version})`).join('\n')
|
|
139
|
+
: undefined;
|
|
140
|
+
|
|
141
|
+
findings.push({
|
|
142
|
+
category: 'module_versions',
|
|
143
|
+
severity: 'drift',
|
|
144
|
+
code: 'module_version_drift',
|
|
145
|
+
message: `${module.id}: ${module.version} → ${info.latest}${tail}`,
|
|
146
|
+
details,
|
|
147
|
+
remediation: 'celilo system update',
|
|
148
|
+
actionable: true,
|
|
149
|
+
subject: module.id,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return findings;
|
|
154
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { DbClient } from '../../db/client';
|
|
3
|
+
import { auditSchema } from './schema';
|
|
4
|
+
|
|
5
|
+
const fakeDb = {} as DbClient;
|
|
6
|
+
|
|
7
|
+
const journalWith = (tags: string[]) => () => ({
|
|
8
|
+
version: '5',
|
|
9
|
+
dialect: 'sqlite',
|
|
10
|
+
entries: tags.map((tag, i) => ({ idx: i, tag, when: 1700000000 + i })),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('auditSchema', () => {
|
|
14
|
+
test('no finding when journal is missing (folder not on disk)', async () => {
|
|
15
|
+
const result = await auditSchema({
|
|
16
|
+
journal: () => null,
|
|
17
|
+
applied: () => [],
|
|
18
|
+
db: fakeDb,
|
|
19
|
+
});
|
|
20
|
+
expect(result).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('no finding when every journal entry is applied', async () => {
|
|
24
|
+
const result = await auditSchema({
|
|
25
|
+
journal: journalWith(['0000_init', '0001_add_zones']),
|
|
26
|
+
applied: () => ['0000_init', '0001_add_zones'],
|
|
27
|
+
db: fakeDb,
|
|
28
|
+
});
|
|
29
|
+
expect(result).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('blocked finding when migrations are pending', async () => {
|
|
33
|
+
const result = await auditSchema({
|
|
34
|
+
journal: journalWith(['0000_init', '0001_add_zones', '0002_backup_tables']),
|
|
35
|
+
applied: () => ['0000_init'],
|
|
36
|
+
db: fakeDb,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(result).toHaveLength(1);
|
|
40
|
+
expect(result[0]).toMatchObject({
|
|
41
|
+
category: 'schema',
|
|
42
|
+
severity: 'blocked',
|
|
43
|
+
code: 'schema_pending_migrations',
|
|
44
|
+
subject: 'system',
|
|
45
|
+
});
|
|
46
|
+
expect(result[0].message).toContain('2 pending');
|
|
47
|
+
expect(result[0].details).toContain('0001_add_zones');
|
|
48
|
+
expect(result[0].details).toContain('0002_backup_tables');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('singular wording for exactly one pending migration', async () => {
|
|
52
|
+
const result = await auditSchema({
|
|
53
|
+
journal: journalWith(['0000_init', '0001_add_zones']),
|
|
54
|
+
applied: () => ['0000_init'],
|
|
55
|
+
db: fakeDb,
|
|
56
|
+
});
|
|
57
|
+
expect(result[0].message).toBe('1 pending DB migration');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('treats applied set as a set (order-independent)', async () => {
|
|
61
|
+
const result = await auditSchema({
|
|
62
|
+
journal: journalWith(['0000_init', '0001_add_zones']),
|
|
63
|
+
applied: () => ['0001_add_zones', '0000_init'], // reversed
|
|
64
|
+
db: fakeDb,
|
|
65
|
+
});
|
|
66
|
+
expect(result).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DB schema drift check.
|
|
3
|
+
*
|
|
4
|
+
* Compares the drizzle migrations folder against the `__drizzle_migrations`
|
|
5
|
+
* table. If the folder has more entries than the table records, a
|
|
6
|
+
* `blocked` finding is produced. In the typical celilo runtime, missing
|
|
7
|
+
* migrations are auto-applied at DB connect time (see
|
|
8
|
+
* `apps/celilo/src/db/client.ts:createDbClient`), so this check is a
|
|
9
|
+
* sanity net for cases where the auto-migrate path was skipped or
|
|
10
|
+
* failed silently.
|
|
11
|
+
*
|
|
12
|
+
* The `journalReader` and `appliedReader` deps are injectable so tests
|
|
13
|
+
* don't need a real drizzle journal on disk or a real DB.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import type { DbClient } from '../../db/client';
|
|
19
|
+
import type { DriftFinding } from './types';
|
|
20
|
+
|
|
21
|
+
interface DrizzleJournalEntry {
|
|
22
|
+
idx: number;
|
|
23
|
+
tag: string;
|
|
24
|
+
when: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface DrizzleJournal {
|
|
28
|
+
version: string;
|
|
29
|
+
dialect: string;
|
|
30
|
+
entries: DrizzleJournalEntry[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type JournalReader = () => DrizzleJournal | null;
|
|
34
|
+
export type AppliedReader = (db: DbClient) => string[];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Default journal reader: parses `<migrationsFolder>/meta/_journal.json`.
|
|
38
|
+
* Returns null if the journal isn't present (callers treat as "nothing to compare").
|
|
39
|
+
*/
|
|
40
|
+
export function makeJournalReader(migrationsFolder: string): JournalReader {
|
|
41
|
+
return () => {
|
|
42
|
+
const journalPath = join(migrationsFolder, 'meta', '_journal.json');
|
|
43
|
+
if (!existsSync(journalPath)) return null;
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(readFileSync(journalPath, 'utf-8')) as DrizzleJournal;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Default applied-migrations reader: queries `__drizzle_migrations`.
|
|
54
|
+
* Returns an empty array if the table doesn't exist (fresh DB).
|
|
55
|
+
*/
|
|
56
|
+
export const readAppliedMigrations: AppliedReader = (db) => {
|
|
57
|
+
// The drizzle migrations table tracks `hash` (which is the migration tag).
|
|
58
|
+
// We use a raw query because the table isn't in our schema definitions.
|
|
59
|
+
try {
|
|
60
|
+
const sqlite = (
|
|
61
|
+
db as unknown as { $client?: { query: (sql: string) => { all: () => unknown[] } } }
|
|
62
|
+
).$client;
|
|
63
|
+
if (!sqlite) return [];
|
|
64
|
+
const rows = sqlite.query('SELECT hash FROM __drizzle_migrations').all() as Array<{
|
|
65
|
+
hash: string;
|
|
66
|
+
}>;
|
|
67
|
+
return rows.map((r) => r.hash);
|
|
68
|
+
} catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export interface SchemaAuditDeps {
|
|
74
|
+
journal: JournalReader;
|
|
75
|
+
applied: AppliedReader;
|
|
76
|
+
db: DbClient;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function auditSchema(deps: SchemaAuditDeps): Promise<DriftFinding[]> {
|
|
80
|
+
const journal = deps.journal();
|
|
81
|
+
if (!journal) return []; // No migrations folder → nothing to compare.
|
|
82
|
+
|
|
83
|
+
// drizzle stores SHA-256 hashes (not tag names) in __drizzle_migrations,
|
|
84
|
+
// so we can't intersect tags. The next-best signal is "did all the
|
|
85
|
+
// journal entries get applied?" — compare counts. The applied count
|
|
86
|
+
// can also exceed the journal count for old DBs with stale tracking
|
|
87
|
+
// rows; only flag when applied < journal.
|
|
88
|
+
const applied = deps.applied(deps.db);
|
|
89
|
+
const pendingCount = journal.entries.length - applied.length;
|
|
90
|
+
if (pendingCount <= 0) return [];
|
|
91
|
+
|
|
92
|
+
// We can identify *which* migrations are pending by treating the
|
|
93
|
+
// journal as ordered: the last `pendingCount` entries are the ones
|
|
94
|
+
// not yet tracked. Drizzle applies them in idx order, so the tail
|
|
95
|
+
// is the unapplied set.
|
|
96
|
+
const sortedJournal = [...journal.entries].sort((a, b) => a.idx - b.idx);
|
|
97
|
+
const pendingEntries = sortedJournal.slice(-pendingCount);
|
|
98
|
+
|
|
99
|
+
return [
|
|
100
|
+
{
|
|
101
|
+
category: 'schema',
|
|
102
|
+
severity: 'blocked',
|
|
103
|
+
code: 'schema_pending_migrations',
|
|
104
|
+
message: `${pendingCount} pending DB migration${pendingCount === 1 ? '' : 's'}`,
|
|
105
|
+
details: pendingEntries.map((e) => ` • ${e.tag}`).join('\n'),
|
|
106
|
+
remediation: [
|
|
107
|
+
'Migrations apply automatically when celilo starts.',
|
|
108
|
+
'Restart any long-running celilo process, or run',
|
|
109
|
+
'bun run apps/celilo/src/db/migrate.ts directly.',
|
|
110
|
+
].join('\n'),
|
|
111
|
+
actionable: false,
|
|
112
|
+
subject: 'system',
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { auditSecretsDecryptable } from './secrets-decryptable';
|
|
3
|
+
|
|
4
|
+
describe('auditSecretsDecryptable', () => {
|
|
5
|
+
test('no findings when every secret decrypts', async () => {
|
|
6
|
+
const result = await auditSecretsDecryptable({
|
|
7
|
+
results: [
|
|
8
|
+
{ scope: 'module', subject: 'caddy', name: 'acme_token', error: null },
|
|
9
|
+
{ scope: 'system', subject: 'system', name: 'master', error: null },
|
|
10
|
+
],
|
|
11
|
+
});
|
|
12
|
+
expect(result).toEqual([]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('per-secret findings when some decrypt and others fail', async () => {
|
|
16
|
+
const result = await auditSecretsDecryptable({
|
|
17
|
+
results: [
|
|
18
|
+
{ scope: 'module', subject: 'caddy', name: 'good', error: null },
|
|
19
|
+
{
|
|
20
|
+
scope: 'module',
|
|
21
|
+
subject: 'iptables',
|
|
22
|
+
name: 'bad',
|
|
23
|
+
error: 'Unsupported state or unable to authenticate data',
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
expect(result).toHaveLength(1);
|
|
28
|
+
expect(result[0]).toMatchObject({
|
|
29
|
+
category: 'secrets_decryptable',
|
|
30
|
+
severity: 'blocked',
|
|
31
|
+
subject: 'iptables',
|
|
32
|
+
actionable: false,
|
|
33
|
+
});
|
|
34
|
+
expect(result[0].code).toContain('module');
|
|
35
|
+
expect(result[0].remediation).toContain('celilo module secret set iptables bad');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('collapses to "master_key_mismatch" when every secret fails identically', async () => {
|
|
39
|
+
const result = await auditSecretsDecryptable({
|
|
40
|
+
results: [
|
|
41
|
+
{ scope: 'module', subject: 'caddy', name: 'a', error: 'bad auth tag' },
|
|
42
|
+
{ scope: 'module', subject: 'iptables', name: 'b', error: 'bad auth tag' },
|
|
43
|
+
{ scope: 'system', subject: 'system', name: 'c', error: 'bad auth tag' },
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
expect(result).toHaveLength(1);
|
|
47
|
+
expect(result[0]).toMatchObject({
|
|
48
|
+
category: 'secrets_decryptable',
|
|
49
|
+
severity: 'blocked',
|
|
50
|
+
code: 'master_key_mismatch',
|
|
51
|
+
subject: 'system',
|
|
52
|
+
actionable: false,
|
|
53
|
+
});
|
|
54
|
+
expect(result[0].message).toContain('All 3 secrets fail to decrypt');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('does NOT collapse when failures have different errors', async () => {
|
|
58
|
+
const result = await auditSecretsDecryptable({
|
|
59
|
+
results: [
|
|
60
|
+
{ scope: 'module', subject: 'caddy', name: 'a', error: 'bad auth tag' },
|
|
61
|
+
{ scope: 'system', subject: 'system', name: 'b', error: 'corrupted' },
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
expect(result).toHaveLength(2);
|
|
65
|
+
expect(result.every((f) => f.code !== 'master_key_mismatch')).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('different scopes produce different remediation text', async () => {
|
|
69
|
+
// Distinct error messages so the "all-fail-same-reason" collapse
|
|
70
|
+
// doesn't trigger — we want per-scope findings here.
|
|
71
|
+
const result = await auditSecretsDecryptable({
|
|
72
|
+
results: [
|
|
73
|
+
{ scope: 'module', subject: 'caddy', name: 'm', error: 'bad tag' },
|
|
74
|
+
{ scope: 'system', subject: 'system', name: 's', error: 'bad iv' },
|
|
75
|
+
],
|
|
76
|
+
});
|
|
77
|
+
const moduleFinding = result.find((f) => f.subject === 'caddy');
|
|
78
|
+
const systemFinding = result.find((f) => f.subject === 'system');
|
|
79
|
+
expect(moduleFinding?.remediation).toContain('celilo module secret set');
|
|
80
|
+
expect(systemFinding?.remediation).toContain('celilo system secret set');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets-decryptable check.
|
|
3
|
+
*
|
|
4
|
+
* Walks every encrypted secret in the celilo DB (module secrets,
|
|
5
|
+
* system secrets, capability secrets) and verifies it decrypts
|
|
6
|
+
* cleanly with the current master key. Catches:
|
|
7
|
+
*
|
|
8
|
+
* - Master key rotated without re-encrypting existing secrets.
|
|
9
|
+
* - Encryption envelope corrupted by external write.
|
|
10
|
+
* - Algorithm mismatch (key length / GCM tag failure).
|
|
11
|
+
*
|
|
12
|
+
* When *every* secret fails with the same error, we collapse to a
|
|
13
|
+
* single "master key mismatch" finding instead of N identical ones —
|
|
14
|
+
* the user-facing signal is "the master key is wrong", not "secret
|
|
15
|
+
* X is broken".
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { DriftFinding } from './types';
|
|
19
|
+
|
|
20
|
+
export type SecretScope = 'module' | 'system' | 'capability';
|
|
21
|
+
|
|
22
|
+
export interface SecretCheckResult {
|
|
23
|
+
scope: SecretScope;
|
|
24
|
+
/** Module ID for module/capability secrets; 'system' for system secrets. */
|
|
25
|
+
subject: string;
|
|
26
|
+
/** Secret name (e.g. `router_password`, `proxmox_api_token`). */
|
|
27
|
+
name: string;
|
|
28
|
+
/** null on success; error message on failure. */
|
|
29
|
+
error: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SecretsDecryptableAuditDeps {
|
|
33
|
+
results: SecretCheckResult[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function auditSecretsDecryptable(
|
|
37
|
+
deps: SecretsDecryptableAuditDeps,
|
|
38
|
+
): Promise<DriftFinding[]> {
|
|
39
|
+
const failed = deps.results.filter((r) => r.error !== null);
|
|
40
|
+
if (failed.length === 0) return [];
|
|
41
|
+
|
|
42
|
+
// If every secret fails with the same error class, collapse to one
|
|
43
|
+
// finding pointing at the master key — that's the actual problem,
|
|
44
|
+
// not N broken records.
|
|
45
|
+
if (failed.length === deps.results.length && deps.results.length > 1) {
|
|
46
|
+
const errorBuckets = new Map<string, number>();
|
|
47
|
+
for (const r of failed) {
|
|
48
|
+
const key = (r.error ?? '').slice(0, 60);
|
|
49
|
+
errorBuckets.set(key, (errorBuckets.get(key) ?? 0) + 1);
|
|
50
|
+
}
|
|
51
|
+
if (errorBuckets.size === 1) {
|
|
52
|
+
const [reason] = errorBuckets.keys();
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
category: 'secrets_decryptable',
|
|
56
|
+
severity: 'blocked',
|
|
57
|
+
code: 'master_key_mismatch',
|
|
58
|
+
message: `All ${failed.length} secrets fail to decrypt — master key may be wrong or rotated`,
|
|
59
|
+
details: reason,
|
|
60
|
+
remediation: [
|
|
61
|
+
'Every secret in the celilo DB fails to decrypt with the',
|
|
62
|
+
'current master key. Common causes:',
|
|
63
|
+
'',
|
|
64
|
+
' 1. Master key file replaced (e.g. machine restored from',
|
|
65
|
+
' a backup that includes a different master key).',
|
|
66
|
+
' 2. Master key file corrupted.',
|
|
67
|
+
' 3. DB restored from a backup taken under a different',
|
|
68
|
+
' master key.',
|
|
69
|
+
'',
|
|
70
|
+
'If you have the previous master key, restore it. If not,',
|
|
71
|
+
"you'll need to re-add every secret manually.",
|
|
72
|
+
].join('\n'),
|
|
73
|
+
actionable: false,
|
|
74
|
+
subject: 'system',
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Otherwise, per-secret findings.
|
|
81
|
+
return failed.map((r) => ({
|
|
82
|
+
category: 'secrets_decryptable' as const,
|
|
83
|
+
severity: 'blocked' as const,
|
|
84
|
+
code: `secret_decrypt_failed_${r.scope}`,
|
|
85
|
+
message: `${r.scope} secret "${r.name}" (${r.subject}) failed to decrypt`,
|
|
86
|
+
details: r.error ?? undefined,
|
|
87
|
+
remediation:
|
|
88
|
+
r.scope === 'module'
|
|
89
|
+
? `Re-set the secret:\n celilo module secret set ${r.subject} ${r.name} <value>`
|
|
90
|
+
: r.scope === 'system'
|
|
91
|
+
? `Re-set the secret:\n celilo system secret set ${r.name} <value>`
|
|
92
|
+
: 'Re-set the capability secret via the owning module.',
|
|
93
|
+
// Multi-step (need plaintext value); not a one-keypress fix.
|
|
94
|
+
actionable: false,
|
|
95
|
+
subject: r.subject,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { auditServicesCredentials } from './services-credentials';
|
|
3
|
+
|
|
4
|
+
describe('auditServicesCredentials', () => {
|
|
5
|
+
test('no findings when every service decrypts', async () => {
|
|
6
|
+
const result = await auditServicesCredentials({
|
|
7
|
+
results: [
|
|
8
|
+
{
|
|
9
|
+
serviceId: 'home-proxmox',
|
|
10
|
+
name: 'Home Proxmox',
|
|
11
|
+
providerName: 'proxmox',
|
|
12
|
+
error: null,
|
|
13
|
+
},
|
|
14
|
+
{ serviceId: 'do', name: 'DigitalOcean', providerName: 'digitalocean', error: null },
|
|
15
|
+
],
|
|
16
|
+
});
|
|
17
|
+
expect(result).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('blocked finding per failing service', async () => {
|
|
21
|
+
const result = await auditServicesCredentials({
|
|
22
|
+
results: [
|
|
23
|
+
{
|
|
24
|
+
serviceId: 'home-proxmox',
|
|
25
|
+
name: 'Home Proxmox',
|
|
26
|
+
providerName: 'proxmox',
|
|
27
|
+
error: 'Failed to decrypt: bad authentication tag',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
expect(result).toHaveLength(1);
|
|
32
|
+
expect(result[0]).toMatchObject({
|
|
33
|
+
category: 'services_credentials',
|
|
34
|
+
severity: 'blocked',
|
|
35
|
+
code: 'service_credentials_invalid',
|
|
36
|
+
subject: 'home-proxmox',
|
|
37
|
+
actionable: false,
|
|
38
|
+
});
|
|
39
|
+
expect(result[0].message).toContain('Home Proxmox');
|
|
40
|
+
expect(result[0].message).toContain('proxmox');
|
|
41
|
+
expect(result[0].details).toContain('bad authentication tag');
|
|
42
|
+
expect(result[0].remediation).toContain('celilo service set-credentials home-proxmox');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('mixed report — only failures produce findings', async () => {
|
|
46
|
+
const result = await auditServicesCredentials({
|
|
47
|
+
results: [
|
|
48
|
+
{ serviceId: 'good', name: 'Good', providerName: 'proxmox', error: null },
|
|
49
|
+
{ serviceId: 'bad', name: 'Bad', providerName: 'proxmox', error: 'oops' },
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
expect(result.map((f) => f.subject)).toEqual(['bad']);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Services-credentials check.
|
|
3
|
+
*
|
|
4
|
+
* For each registered container service, verifies its API credentials
|
|
5
|
+
* are present and decryptable with the current master key. Catches
|
|
6
|
+
* failure modes that today only surface at deploy time:
|
|
7
|
+
*
|
|
8
|
+
* - Service registered but credentials never set (column null/empty).
|
|
9
|
+
* - Master key rotated; old envelope no longer decrypts.
|
|
10
|
+
* - Stored envelope is corrupt or doesn't match the provider's
|
|
11
|
+
* credential schema (e.g. Proxmox service has DigitalOcean-shaped
|
|
12
|
+
* credentials).
|
|
13
|
+
*
|
|
14
|
+
* Each finding is BLOCKED — `system update` would otherwise fan
|
|
15
|
+
* these out as opaque terraform / SSH errors. Surfacing them up
|
|
16
|
+
* front lets the user re-run `celilo service set-credentials`
|
|
17
|
+
* before any deploy work.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { DriftFinding } from './types';
|
|
21
|
+
|
|
22
|
+
export interface ServiceCredentialsResult {
|
|
23
|
+
/** Stable service identifier (the user-facing kebab-case ID). */
|
|
24
|
+
serviceId: string;
|
|
25
|
+
/** Display name for the finding message. */
|
|
26
|
+
name: string;
|
|
27
|
+
providerName: string;
|
|
28
|
+
/** null on success; error message on failure. */
|
|
29
|
+
error: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ServicesCredentialsAuditDeps {
|
|
33
|
+
results: ServiceCredentialsResult[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function auditServicesCredentials(
|
|
37
|
+
deps: ServicesCredentialsAuditDeps,
|
|
38
|
+
): Promise<DriftFinding[]> {
|
|
39
|
+
const findings: DriftFinding[] = [];
|
|
40
|
+
|
|
41
|
+
for (const r of deps.results) {
|
|
42
|
+
if (r.error === null) continue;
|
|
43
|
+
|
|
44
|
+
findings.push({
|
|
45
|
+
category: 'services_credentials',
|
|
46
|
+
severity: 'blocked',
|
|
47
|
+
code: 'service_credentials_invalid',
|
|
48
|
+
message: `${r.name} (${r.providerName}): credentials missing or invalid`,
|
|
49
|
+
details: r.error,
|
|
50
|
+
remediation: [
|
|
51
|
+
'Re-add the credentials for this service. For example:',
|
|
52
|
+
` celilo service set-credentials ${r.serviceId}`,
|
|
53
|
+
'or remove the service:',
|
|
54
|
+
` celilo service remove ${r.name}`,
|
|
55
|
+
].join('\n'),
|
|
56
|
+
// Multi-step (interactive prompts for credential values), so
|
|
57
|
+
// not a one-keypress remediation in the TUI.
|
|
58
|
+
actionable: false,
|
|
59
|
+
subject: r.serviceId,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return findings;
|
|
64
|
+
}
|