@celilo/cli 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +18 -2
  3. package/MODULE_PRIMITIVES.md +25 -7
  4. package/drizzle/0026_module_integrity_version.sql +20 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +3 -3
  7. package/src/capabilities/lookup.ts +39 -29
  8. package/src/capabilities/secret-ref.test.ts +24 -0
  9. package/src/capabilities/secret-validation.ts +50 -0
  10. package/src/capabilities/validation.test.ts +187 -2
  11. package/src/capabilities/validation.ts +53 -1
  12. package/src/cli/commands/alerts-sweep.ts +18 -0
  13. package/src/cli/commands/module-audit.ts +5 -2
  14. package/src/cli/commands/module-remove.ts +34 -2
  15. package/src/cli/commands/module-update.test.ts +238 -3
  16. package/src/cli/commands/module-update.ts +206 -12
  17. package/src/cli/commands/module-verify.ts +77 -13
  18. package/src/cli/commands/service-set-credentials.test.ts +108 -0
  19. package/src/cli/commands/service-set-credentials.ts +115 -0
  20. package/src/cli/commands/system-audit.ts +17 -0
  21. package/src/cli/commands/system-doctor.ts +78 -2
  22. package/src/cli/commands/system-migrate.ts +6 -4
  23. package/src/cli/commands/system-update.ts +33 -3
  24. package/src/cli/completion.ts +16 -1
  25. package/src/cli/index.ts +11 -2
  26. package/src/cli/tui/audit-state.ts +11 -3
  27. package/src/cli/tui/audit-tui.tsx +10 -4
  28. package/src/cli/tui/icons.ts +9 -2
  29. package/src/cli/tui/modals/analyzing.tsx +3 -0
  30. package/src/db/client.ts +10 -8
  31. package/src/db/migrate.test.ts +147 -0
  32. package/src/db/migrate.ts +69 -1
  33. package/src/db/schema.ts +5 -0
  34. package/src/hooks/capability-loader.test.ts +55 -0
  35. package/src/hooks/capability-loader.ts +16 -1
  36. package/src/manifest/json-schema-roundtrip.test.ts +12 -4
  37. package/src/manifest/schema.ts +23 -0
  38. package/src/module/import.ts +56 -40
  39. package/src/module/packaging/audit.ts +103 -28
  40. package/src/module/packaging/build.ts +12 -53
  41. package/src/module/packaging/classify-module-path.test.ts +104 -0
  42. package/src/module/packaging/extract.ts +31 -3
  43. package/src/module/packaging/generated-plane.test.ts +79 -0
  44. package/src/module/packaging/generated-plane.ts +134 -0
  45. package/src/module/packaging/host-plane.test.ts +132 -0
  46. package/src/module/packaging/host-plane.ts +135 -0
  47. package/src/module/packaging/package-rules.ts +62 -0
  48. package/src/policy/module-business-baseline.ts +0 -11
  49. package/src/services/alerting/monitors.ts +54 -2
  50. package/src/services/alerting/sweep-runner.ts +38 -1
  51. package/src/services/audit/cli-version.test.ts +6 -2
  52. package/src/services/audit/cli-version.ts +20 -6
  53. package/src/services/audit/detect-without-converge.test.ts +91 -0
  54. package/src/services/audit/detect-without-converge.ts +81 -0
  55. package/src/services/audit/disk-space.test.ts +5 -2
  56. package/src/services/audit/disk-space.ts +5 -3
  57. package/src/services/audit/health.test.ts +39 -0
  58. package/src/services/audit/index.test.ts +7 -1
  59. package/src/services/audit/index.ts +12 -0
  60. package/src/services/audit/module-integrity.test.ts +146 -0
  61. package/src/services/audit/module-integrity.ts +113 -0
  62. package/src/services/audit/module-versions.ts +4 -1
  63. package/src/services/audit/schema.test.ts +7 -2
  64. package/src/services/audit/schema.ts +19 -1
  65. package/src/services/audit/terraform-plan.ts +17 -2
  66. package/src/services/audit/types.test.ts +29 -0
  67. package/src/services/audit/types.ts +30 -4
  68. package/src/services/consumer-cleanup.ts +5 -3
  69. package/src/services/container-service.test.ts +34 -0
  70. package/src/services/container-service.ts +44 -0
  71. package/src/services/deployed-systems.test.ts +101 -0
  72. package/src/services/deployed-systems.ts +43 -11
  73. package/src/services/dns-provider-backfill.ts +30 -0
  74. package/src/services/fleet-checks.test.ts +26 -0
  75. package/src/services/fleet-checks.ts +11 -1
  76. package/src/services/module-deploy.ts +109 -41
  77. package/src/services/provider-arrival.test.ts +241 -0
  78. package/src/services/provider-arrival.ts +213 -0
  79. package/src/services/restore-from-file.ts +4 -0
  80. package/src/services/update/orchestrator.test.ts +2 -0
  81. package/src/templates/generator.test.ts +35 -0
  82. package/src/templates/generator.ts +29 -1
  83. package/src/variables/context.test.ts +63 -0
  84. package/src/variables/context.ts +10 -2
  85. package/src/variables/declarative-derivation.test.ts +47 -8
  86. package/src/variables/declarative-derivation.ts +6 -4
  87. package/src/services/public-web-republish.test.ts +0 -189
  88. package/src/services/public-web-republish.ts +0 -84
@@ -0,0 +1,146 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { AuditResult } from '../../module/packaging/audit';
3
+ import { auditModuleIntegrity } from './module-integrity';
4
+ import { computeVerdict } from './types';
5
+
6
+ const clean = (moduleId: string): AuditResult => ({
7
+ success: true,
8
+ moduleId,
9
+ violations: [],
10
+ moduleVersion: '1.0.0',
11
+ baselineVersion: '1.0.0',
12
+ });
13
+
14
+ describe('auditModuleIntegrity', () => {
15
+ test('a clean module produces nothing', () => {
16
+ expect(auditModuleIntegrity({ results: [clean('caddy')] })).toEqual([]);
17
+ });
18
+
19
+ test('a check that could not run is unmeasured, and the verdict is UNKNOWN', () => {
20
+ const findings = auditModuleIntegrity({
21
+ results: [
22
+ {
23
+ success: false,
24
+ moduleId: 'caddy',
25
+ violations: [],
26
+ error: "No integrity data found for module 'caddy'.",
27
+ },
28
+ ],
29
+ });
30
+ expect(findings.map((f) => f.severity)).toEqual(['unmeasured']);
31
+ expect(computeVerdict(findings)).toBe('UNKNOWN');
32
+ });
33
+
34
+ test('a stale baseline is unmeasured, and it SUPPRESSES the file findings it explains', () => {
35
+ // The 72-violation shape. One frozen row made every legitimately-changed
36
+ // file read as [MODIFIED], and printing all of them is what made `module
37
+ // verify` unreadable. The baseline is the finding; the files are its
38
+ // consequence.
39
+ const findings = auditModuleIntegrity({
40
+ results: [
41
+ {
42
+ ...clean('wireguard'),
43
+ success: false,
44
+ baselineVersion: '0.7.0',
45
+ moduleVersion: '0.8.0',
46
+ violations: [
47
+ {
48
+ type: 'stale-baseline',
49
+ path: 'checksums.json',
50
+ message: 'Baseline describes 0.7.0, module records 0.8.0.',
51
+ },
52
+ { type: 'modified', path: 'manifest.yml', message: 'Checksum mismatch: manifest.yml' },
53
+ { type: 'extra', path: 'scripts/new.ts', message: 'Unexpected file: scripts/new.ts' },
54
+ ],
55
+ },
56
+ ],
57
+ });
58
+ expect(findings).toHaveLength(1);
59
+ expect(findings[0]?.code).toBe('module_integrity_stale_baseline');
60
+ expect(findings[0]?.severity).toBe('unmeasured');
61
+ expect(findings[0]?.remediation).toBe('celilo module update wireguard');
62
+ });
63
+
64
+ test('file drift under a TRUSTWORTHY baseline is drift, and lists every file', () => {
65
+ const findings = auditModuleIntegrity({
66
+ results: [
67
+ {
68
+ ...clean('wireguard'),
69
+ success: false,
70
+ violations: [
71
+ { type: 'modified', path: 'manifest.yml', message: 'Checksum mismatch: manifest.yml' },
72
+ { type: 'extra', path: 'scripts/new.ts', message: 'Unexpected file: scripts/new.ts' },
73
+ ],
74
+ },
75
+ ],
76
+ });
77
+ expect(findings).toHaveLength(1);
78
+ expect(findings[0]?.severity).toBe('drift');
79
+ // Never truncated to the first item. celilo#951's real finding would have
80
+ // arrived at position 19 of 19 and never been printed.
81
+ expect(findings[0]?.details).toContain('manifest.yml');
82
+ expect(findings[0]?.details).toContain('scripts/new.ts');
83
+ });
84
+
85
+ test('a stale generated project is drift, and says a deploy would ship the wrong bytes', () => {
86
+ const findings = auditModuleIntegrity({
87
+ results: [
88
+ {
89
+ ...clean('wireguard-manager'),
90
+ success: false,
91
+ violations: [
92
+ {
93
+ type: 'stale-generated',
94
+ path: 'ansible/roles/vpn/files/bin',
95
+ message: 'Generated project holds different bytes',
96
+ },
97
+ ],
98
+ },
99
+ ],
100
+ });
101
+ expect(findings.map((f) => f.code)).toEqual(['module_generated_stale']);
102
+ expect(findings[0]?.severity).toBe('drift');
103
+ expect(findings[0]?.remediation).toBe('celilo module generate wireguard-manager');
104
+ });
105
+
106
+ test('host-plane results carry through with the right severity', () => {
107
+ const findings = auditModuleIntegrity({
108
+ results: [
109
+ {
110
+ ...clean('wireguard'),
111
+ hostPlane: {
112
+ findings: [
113
+ { hostname: 'a', state: 'converged' },
114
+ { hostname: 'b', state: 'drift', detail: 'would change 2 things' },
115
+ { hostname: 'c', state: 'unmeasured', detail: 'did not answer' },
116
+ ],
117
+ },
118
+ },
119
+ ],
120
+ });
121
+ expect(findings.map((f) => [f.code, f.severity])).toEqual([
122
+ ['module_host_drifted', 'drift'],
123
+ ['module_host_unmeasured', 'unmeasured'],
124
+ ]);
125
+ // An unreachable host must not let the run read as READY.
126
+ expect(computeVerdict(findings)).toBe('UNKNOWN');
127
+ });
128
+
129
+ test('every remediation is a runnable celilo command', () => {
130
+ const findings = auditModuleIntegrity({
131
+ results: [
132
+ { success: false, moduleId: 'a', violations: [], error: 'gone' },
133
+ {
134
+ ...clean('b'),
135
+ success: false,
136
+ violations: [{ type: 'modified', path: 'x', message: 'x' }],
137
+ },
138
+ ],
139
+ });
140
+ expect(findings.length).toBeGreaterThan(0);
141
+ for (const f of findings) {
142
+ expect(`${f.code}: ${f.remediation}`).toMatch(/: celilo /);
143
+ expect(f.actionable).toBe(true);
144
+ }
145
+ });
146
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Module integrity as a drift category
3
+ * (openspec/changes/module-integrity-rigor, D8).
4
+ *
5
+ * One implementation, three surfaces: `celilo module verify <id>` for one
6
+ * module, `celilo system audit` for every module, and `system doctor`'s fleet
7
+ * section. `system doctor` answers "can this box run celilo, and is the runtime
8
+ * wired up"; `system audit` answers "has anything drifted from desired state",
9
+ * and module integrity is a drift category by every structural test.
10
+ *
11
+ * Pure of I/O in the same way its siblings are: the caller does the auditing
12
+ * and passes the results in, so this file is testable without a filesystem.
13
+ */
14
+
15
+ import type { AuditResult } from '../../module/packaging/audit';
16
+ import type { DriftFinding } from './types';
17
+
18
+ export interface ModuleIntegrityAuditDeps {
19
+ /** One `auditModule` result per installed module. */
20
+ results: AuditResult[];
21
+ }
22
+
23
+ /**
24
+ * A verify failure celilo could not perform at all — no baseline row, module
25
+ * directory gone — is `unmeasured`, not `drift`. It is the difference between
26
+ * "the files moved" and "I never looked", which is the whole point of D7.
27
+ */
28
+ export function auditModuleIntegrity(deps: ModuleIntegrityAuditDeps): DriftFinding[] {
29
+ const findings: DriftFinding[] = [];
30
+
31
+ for (const result of deps.results) {
32
+ if (result.error) {
33
+ findings.push({
34
+ category: 'module_integrity',
35
+ severity: 'unmeasured',
36
+ code: 'module_integrity_unmeasured',
37
+ message: `${result.moduleId}: integrity could not be checked — ${result.error}`,
38
+ remediation: `celilo module verify ${result.moduleId}`,
39
+ actionable: true,
40
+ subject: result.moduleId,
41
+ });
42
+ continue;
43
+ }
44
+
45
+ // Ahead of the file findings it explains. When the baseline describes a
46
+ // different version, every file difference beneath it is a consequence of
47
+ // that and not independent evidence.
48
+ const stale = result.violations.filter((v) => v.type === 'stale-baseline');
49
+ for (const violation of stale) {
50
+ findings.push({
51
+ category: 'module_integrity',
52
+ severity: 'unmeasured',
53
+ code: 'module_integrity_stale_baseline',
54
+ message: `${result.moduleId}: ${violation.message}`,
55
+ remediation: `celilo module update ${result.moduleId}`,
56
+ actionable: true,
57
+ subject: result.moduleId,
58
+ });
59
+ }
60
+
61
+ const generated = result.violations.filter((v) => v.type === 'stale-generated');
62
+ if (generated.length > 0) {
63
+ findings.push({
64
+ category: 'module_integrity',
65
+ severity: 'drift',
66
+ code: 'module_generated_stale',
67
+ message: `${result.moduleId}: ${generated.length} generated asset(s) do not match the installed module — a deploy would ship the wrong bytes`,
68
+ details: generated.map((v) => ` • ${v.path}`).join('\n'),
69
+ remediation: `celilo module generate ${result.moduleId}`,
70
+ actionable: true,
71
+ subject: result.moduleId,
72
+ });
73
+ }
74
+
75
+ // File differences are only meaningful once the baseline is trustworthy.
76
+ // Reporting them under a stale baseline is what made `module verify`
77
+ // unreadable: 72 violations across two healthy modules, nearly all of them
78
+ // consequences of one frozen row.
79
+ const files = result.violations.filter(
80
+ (v) => v.type === 'modified' || v.type === 'missing' || v.type === 'extra',
81
+ );
82
+ if (files.length > 0 && stale.length === 0) {
83
+ findings.push({
84
+ category: 'module_integrity',
85
+ severity: 'drift',
86
+ code: 'module_files_drifted',
87
+ message: `${result.moduleId}: ${files.length} installed file(s) do not match the ${result.baselineVersion ?? 'recorded'} baseline`,
88
+ details: files.map((v) => ` • [${v.type}] ${v.path}`).join('\n'),
89
+ remediation: `celilo module verify ${result.moduleId} --json`,
90
+ actionable: true,
91
+ subject: result.moduleId,
92
+ });
93
+ }
94
+
95
+ for (const host of result.hostPlane?.findings ?? []) {
96
+ if (host.state === 'converged') continue;
97
+ findings.push({
98
+ category: 'module_integrity',
99
+ severity: host.state === 'drift' ? 'drift' : 'unmeasured',
100
+ code: host.state === 'drift' ? 'module_host_drifted' : 'module_host_unmeasured',
101
+ message: `${result.moduleId} on ${host.hostname}: ${host.detail}`,
102
+ remediation:
103
+ host.state === 'drift'
104
+ ? `celilo module deploy ${result.moduleId}`
105
+ : `celilo module verify ${result.moduleId} --deep`,
106
+ actionable: true,
107
+ subject: result.moduleId,
108
+ });
109
+ }
110
+ }
111
+
112
+ return findings;
113
+ }
@@ -102,7 +102,10 @@ export async function auditModuleVersions(deps: ModuleVersionsAuditDeps): Promis
102
102
  if (r.status === 'rejected') {
103
103
  findings.push({
104
104
  category: 'module_versions',
105
- severity: 'drift',
105
+ // A registry that did not answer is not a version difference. Reporting
106
+ // it as `drift` said "this module is behind" when the truth was "I
107
+ // could not ask" — the ambiguity D7 exists to remove.
108
+ severity: 'unmeasured',
106
109
  code: 'module_version_lookup_failed',
107
110
  message: `${installed.id}: registry lookup failed (${String(r.reason).slice(0, 80)})`,
108
111
  remediation: 'Check network connectivity to the registry, then press R to re-audit.',
@@ -11,13 +11,18 @@ const journalWith = (tags: string[]) => () => ({
11
11
  });
12
12
 
13
13
  describe('auditSchema', () => {
14
- test('no finding when journal is missing (folder not on disk)', async () => {
14
+ test('an unreadable journal is unmeasured, not clean', async () => {
15
+ // Was `expect(result).toEqual([])`. Silence rendered as READY, and pending
16
+ // migrations are the difference between a `.deb` that installed and a
17
+ // `.deb` that works — `apt upgrade` does not run them (celilo#169).
15
18
  const result = await auditSchema({
16
19
  journal: () => null,
17
20
  applied: () => [],
18
21
  db: fakeDb,
19
22
  });
20
- expect(result).toEqual([]);
23
+ expect(result).toHaveLength(1);
24
+ expect(result[0]?.severity).toBe('unmeasured');
25
+ expect(result[0]?.code).toBe('schema_journal_unreadable');
21
26
  });
22
27
 
23
28
  test('no finding when every journal entry is applied', async () => {
@@ -78,7 +78,25 @@ export interface SchemaAuditDeps {
78
78
 
79
79
  export async function auditSchema(deps: SchemaAuditDeps): Promise<DriftFinding[]> {
80
80
  const journal = deps.journal();
81
- if (!journal) return []; // No migrations folder → nothing to compare.
81
+ if (!journal) {
82
+ // Pending migrations are the difference between a `.deb` that installed and
83
+ // a `.deb` that works — `apt upgrade` does not run them (celilo#169). With
84
+ // no journal there is nothing to compare against, and silence used to
85
+ // render that as READY.
86
+ return [
87
+ {
88
+ category: 'schema',
89
+ severity: 'unmeasured',
90
+ code: 'schema_journal_unreadable',
91
+ message:
92
+ 'No drizzle migration journal could be read, so pending schema migrations are unknown',
93
+ remediation:
94
+ 'The installed @celilo/cli is missing its `drizzle/meta/_journal.json`. Reinstall it, then re-audit. This records that the comparison did not happen, not that the schema is current.',
95
+ actionable: false,
96
+ subject: 'system',
97
+ },
98
+ ];
99
+ }
82
100
 
83
101
  // drizzle stores SHA-256 hashes (not tag names) in __drizzle_migrations,
84
102
  // so we can't intersect tags. The next-best signal is "did all the
@@ -112,8 +112,23 @@ export async function auditTerraformPlan(deps: TerraformPlanAuditDeps): Promise<
112
112
  }
113
113
 
114
114
  const summary = parsePlanSummary(result.stdout);
115
- if (!summary || (summary.add === 0 && summary.change === 0 && summary.destroy === 0)) {
116
- // Plan is empty or unparseable-but-non-error no drift.
115
+ if (!summary) {
116
+ // Terraform exited zero and celilo could not read its answer. That is not
117
+ // "no drift" — it is no measurement, and it used to be indistinguishable
118
+ // from a clean plan (D7).
119
+ findings.push({
120
+ category: 'terraform_plan',
121
+ severity: 'unmeasured',
122
+ code: 'terraform_plan_unparseable',
123
+ message: `${m.id}: terraform plan succeeded but its summary could not be parsed, so infrastructure drift is unknown`,
124
+ details: result.stdout.slice(0, 500),
125
+ remediation: `Run terraform plan in ${m.terraformDir} and read it directly. This records that the comparison did not happen, not that the infrastructure matches.`,
126
+ actionable: false,
127
+ subject: m.id,
128
+ });
129
+ continue;
130
+ }
131
+ if (summary.add === 0 && summary.change === 0 && summary.destroy === 0) {
117
132
  continue;
118
133
  }
119
134
 
@@ -17,6 +17,14 @@ const blocked: DriftFinding = {
17
17
  subject: 'lunacycle',
18
18
  };
19
19
 
20
+ const unmeasured: DriftFinding = {
21
+ category: 'module_integrity',
22
+ severity: 'unmeasured',
23
+ code: 'module_integrity_unmeasured',
24
+ message: 'wireguard-manager: the host did not answer, so nothing was measured',
25
+ subject: 'wireguard-manager',
26
+ };
27
+
20
28
  const todo: DriftFinding = {
21
29
  category: 'undeployed_modules',
22
30
  severity: 'todo',
@@ -59,4 +67,25 @@ describe('computeVerdict', () => {
59
67
  test('BLOCKED still wins over todos', () => {
60
68
  expect(computeVerdict([todo, drift, blocked])).toBe('BLOCKED');
61
69
  });
70
+
71
+ // D7. An unmeasured check is not a pass, and it is not the same statement as
72
+ // a measured difference. Before this, a category that could not reach its
73
+ // subject contributed nothing, and contributing nothing rendered as READY.
74
+ test('UNKNOWN when something could not be measured', () => {
75
+ expect(computeVerdict([unmeasured])).toBe('UNKNOWN');
76
+ });
77
+
78
+ test('an unmeasured finding never lets the verdict return READY', () => {
79
+ expect(computeVerdict([todo, unmeasured])).not.toBe('READY');
80
+ });
81
+
82
+ test('UNKNOWN outranks DRIFT — you cannot act on a diff you are not sure you have', () => {
83
+ expect(computeVerdict([drift, unmeasured])).toBe('UNKNOWN');
84
+ expect(computeVerdict([unmeasured, drift])).toBe('UNKNOWN');
85
+ });
86
+
87
+ test('BLOCKED still outranks UNKNOWN — a hard stop is still a hard stop', () => {
88
+ expect(computeVerdict([unmeasured, blocked])).toBe('BLOCKED');
89
+ expect(computeVerdict([blocked, unmeasured, drift, todo])).toBe('BLOCKED');
90
+ });
62
91
  });
@@ -9,7 +9,8 @@
9
9
  *
10
10
  * The overall verdict is computed from the findings:
11
11
  * - any `blocked` finding → BLOCKED
12
- * - any `drift` finding (no blocked) → DRIFT
12
+ * - any `unmeasured` finding (no blocked) → UNKNOWN
13
+ * - any `drift` finding (no blocked, no unmeasured) → DRIFT
13
14
  * - only `todo` findings (or none) → READY
14
15
  *
15
16
  * `todo` exists because some categories surface "next-step reminders"
@@ -42,11 +43,35 @@ export type DriftCategory =
42
43
  | 'disk_space'
43
44
  | 'transport_reads'
44
45
  | 'trusted_sources'
45
- | 'interface_classification';
46
+ | 'interface_classification'
47
+ | 'module_integrity'
48
+ | 'detect_without_converge';
46
49
 
47
- export type DriftSeverity = 'todo' | 'drift' | 'blocked';
50
+ /**
51
+ * `unmeasured` is a check that could not reach its subject
52
+ * (openspec/changes/module-integrity-rigor, D7).
53
+ *
54
+ * It exists because there was nowhere to put that. A category that could not
55
+ * measure either invented a finding of the wrong severity or contributed
56
+ * nothing, and contributing nothing renders as READY — the same defect as
57
+ * everything else this change is about, sitting in the framework that is
58
+ * supposed to catch it.
59
+ *
60
+ * Every claim celilo makes about the fleet is either measured against the thing
61
+ * it describes, or reported as unmeasured. There is no third category, and
62
+ * unmeasured never renders as green.
63
+ */
64
+ export type DriftSeverity = 'todo' | 'drift' | 'unmeasured' | 'blocked';
48
65
 
49
- export type AuditVerdict = 'READY' | 'DRIFT' | 'BLOCKED';
66
+ /**
67
+ * `UNKNOWN` is deliberately distinct from `DRIFT` rather than folded into it.
68
+ * "I could not tell" and "I can tell, and it is wrong" call for different
69
+ * operator responses, and collapsing them recreates exactly the ambiguity D1
70
+ * exists to remove one level down. It ranks below `BLOCKED` — a hard stop is
71
+ * still a hard stop — and above `DRIFT`, because you cannot act on a diff you
72
+ * are not sure you have.
73
+ */
74
+ export type AuditVerdict = 'READY' | 'DRIFT' | 'UNKNOWN' | 'BLOCKED';
50
75
 
51
76
  /**
52
77
  * A single drift finding produced by a category check.
@@ -102,6 +127,7 @@ export interface SystemAuditReport {
102
127
  */
103
128
  export function computeVerdict(findings: DriftFinding[]): AuditVerdict {
104
129
  if (findings.some((f) => f.severity === 'blocked')) return 'BLOCKED';
130
+ if (findings.some((f) => f.severity === 'unmeasured')) return 'UNKNOWN';
105
131
  if (findings.some((f) => f.severity === 'drift')) return 'DRIFT';
106
132
  return 'READY';
107
133
  }
@@ -31,10 +31,12 @@ import { deleteTrustedSourcesForModule } from './trusted-sources';
31
31
  * nothing minted on anyone's behalf. Capabilities are registered at IMPORT, not
32
32
  * deploy, so the `capabilities` table routinely names providers that were never
33
33
  * deployed. The same predicate `remove-guard.ts` uses to decide a module is not
34
- * a dependent — the guard and the cleanup must keep ONE definition of a live
35
- * provider (D8).
34
+ * a dependent — the guard, the cleanup and the provider-arrival backfill must
35
+ * keep ONE definition of a live module (D8). Exported rather than re-spelled:
36
+ * `module-remove.ts` had its own copy of the literal, and `provider-arrival.ts`
37
+ * would have been a third.
36
38
  */
37
- const PRE_DEPLOY_STATES = new Set(['IMPORTED', 'VALIDATED', 'CONFIGURED']);
39
+ export const PRE_DEPLOY_STATES = new Set(['IMPORTED', 'VALIDATED', 'CONFIGURED']);
38
40
 
39
41
  export type CleanupSkipReason = 'paused' | 'not-deployed';
40
42
 
@@ -17,6 +17,8 @@ import {
17
17
  getServiceCredentials,
18
18
  listContainerServices,
19
19
  removeContainerService,
20
+ updateServiceCredentials,
21
+ updateVerificationStatus,
20
22
  } from './container-service';
21
23
 
22
24
  describe('container-service', () => {
@@ -270,6 +272,38 @@ describe('container-service', () => {
270
272
  /Container service not found/,
271
273
  );
272
274
  });
275
+
276
+ it('re-encrypts replacements and clears stale verification state', async () => {
277
+ const service = await addContainerService({
278
+ name: 'Moving Proxmox',
279
+ providerName: 'proxmox',
280
+ zones: ['internal'],
281
+ providerConfig: {},
282
+ apiCredentials: {
283
+ api_url: 'https://192.168.0.50:8006',
284
+ api_token_id: 'root@pam!celilo',
285
+ api_token_secret: 'existing-secret',
286
+ },
287
+ });
288
+ await updateVerificationStatus(service.id, { success: true, message: 'Connected' });
289
+
290
+ await updateServiceCredentials(service.id, {
291
+ api_url: 'https://10.77.20.50:8006',
292
+ api_token_id: 'root@pam!celilo',
293
+ api_token_secret: 'existing-secret',
294
+ });
295
+
296
+ const credentials = await getServiceCredentials(service.id);
297
+ const updated = await getContainerService(service.id);
298
+ expect(credentials).toEqual({
299
+ api_url: 'https://10.77.20.50:8006',
300
+ api_token_id: 'root@pam!celilo',
301
+ api_token_secret: 'existing-secret',
302
+ });
303
+ expect(updated?.verified).toBe(false);
304
+ expect(updated?.verifiedAt).toBeNull();
305
+ expect(updated?.verificationError).toBeNull();
306
+ });
273
307
  });
274
308
 
275
309
  describe('removeContainerService', () => {
@@ -251,6 +251,50 @@ export async function getServiceCredentials(serviceId: string): Promise<ServiceC
251
251
  );
252
252
  }
253
253
 
254
+ /**
255
+ * Replace a container service's encrypted API credentials.
256
+ *
257
+ * Credentials identify the remote provider endpoint as well as the principal
258
+ * used there, so changing either invalidates the previous verification result.
259
+ * Callers should explicitly re-run service verification after this update.
260
+ */
261
+ export async function updateServiceCredentials(
262
+ id: string,
263
+ credentials: ServiceCredentials,
264
+ ): Promise<void> {
265
+ const service = await getContainerService(id);
266
+ if (!service) {
267
+ throw new Error(`Container service not found: ${id}`);
268
+ }
269
+
270
+ const validated =
271
+ service.providerName === 'proxmox'
272
+ ? ProxmoxCredentialsSchema.parse(credentials)
273
+ : service.providerName === 'digitalocean'
274
+ ? DigitalOceanCredentialsSchema.parse(credentials)
275
+ : null;
276
+
277
+ if (!validated) {
278
+ throw new Error(
279
+ `Unsupported provider for credential validation: ${service.providerName}. Supported providers: proxmox, digitalocean`,
280
+ );
281
+ }
282
+
283
+ const masterKey = await getOrCreateMasterKey();
284
+ const encrypted = encryptSecret(JSON.stringify(validated), masterKey);
285
+
286
+ await getDb()
287
+ .update(containerServices)
288
+ .set({
289
+ apiCredentialsEncrypted: JSON.stringify(encrypted),
290
+ verified: false,
291
+ verifiedAt: null,
292
+ verificationError: null,
293
+ updatedAt: new Date(),
294
+ })
295
+ .where(eq(containerServices.id, id));
296
+ }
297
+
254
298
  /**
255
299
  * List container services with optional filters
256
300
  */
@@ -266,6 +266,57 @@ describe('backfillModuleSystems', () => {
266
266
  });
267
267
  });
268
268
 
269
+ test('backfill records a local machine by its interface in the resolved zone', () => {
270
+ db.insert(machines)
271
+ .values({
272
+ id: 'm-local-bf',
273
+ hostname: 'celilo-mgr',
274
+ // Local-execution sentinel, not the machine's network identity.
275
+ ipAddress: '127.0.0.1',
276
+ sshUser: 'jem',
277
+ sshKeyEncrypted: JSON.stringify({ encryptedValue: '', iv: '', authTag: '' }),
278
+ hardware: { cpu_cores: 4, memory_mb: 8192, disk_gb: 64 },
279
+ zone: 'secure-mgmt',
280
+ interfaces: [
281
+ { name: 'en0', ipAddress: '192.168.0.32', zone: 'external' },
282
+ { name: 'ens18', ipAddress: '10.77.20.32', zone: 'secure-mgmt' },
283
+ ],
284
+ })
285
+ .run();
286
+ db.insert(modules)
287
+ .values({
288
+ id: 'celilo-mgmt',
289
+ name: 'celilo-mgmt',
290
+ version: '1.0.0',
291
+ manifestData: { requires: { system: { zone: 'internal' } } },
292
+ sourcePath: '/tmp/celilo-mgmt',
293
+ state: 'VERIFIED',
294
+ })
295
+ .run();
296
+ db.insert(moduleInfrastructure)
297
+ .values({
298
+ id: 'infra-celilo-mgmt-local',
299
+ moduleId: 'celilo-mgmt',
300
+ infrastructureType: 'machine',
301
+ machineId: 'm-local-bf',
302
+ })
303
+ .run();
304
+ db.insert(moduleConfigs)
305
+ .values({
306
+ moduleId: 'celilo-mgmt',
307
+ key: 'hostname',
308
+ value: 'celilo-mgr',
309
+ valueJson: '"celilo-mgr"',
310
+ })
311
+ .run();
312
+
313
+ expect(backfillModuleSystems(db)).toEqual(['celilo-mgmt']);
314
+ expect(getModuleSystems('celilo-mgmt', db)[0]).toMatchObject({
315
+ zone: 'secure-mgmt',
316
+ ipv4_address: '10.77.20.32',
317
+ });
318
+ });
319
+
269
320
  // The control-plane case. celilo-mgmt declares `internal` (it bootstraps before
270
321
  // any firewall exists) but in a segmented fleet the operator earmarks a box on
271
322
  // `secure-mgmt`. What gets RECORDED must be where the system actually is, since
@@ -318,6 +369,56 @@ describe('backfillModuleSystems', () => {
318
369
  expect(getModuleSystems('celilo-mgmt', db)[0]).toMatchObject({ zone: 'secure-mgmt' });
319
370
  });
320
371
 
372
+ test('records a local machine by its interface in the resolved zone', async () => {
373
+ db.insert(machines)
374
+ .values({
375
+ id: 'm-local',
376
+ hostname: 'celilo-mgr',
377
+ // Local-execution sentinel, not the machine's network identity.
378
+ ipAddress: '127.0.0.1',
379
+ sshUser: 'jem',
380
+ sshKeyEncrypted: JSON.stringify({ encryptedValue: '', iv: '', authTag: '' }),
381
+ hardware: { cpu_cores: 4, memory_mb: 8192, disk_gb: 64 },
382
+ zone: 'secure-mgmt',
383
+ earmarkedModule: 'celilo-mgmt',
384
+ interfaces: [
385
+ { name: 'en0', ipAddress: '192.168.0.32', zone: 'external' },
386
+ { name: 'ens18', ipAddress: '10.77.20.32', zone: 'secure-mgmt' },
387
+ ],
388
+ })
389
+ .run();
390
+ db.insert(modules)
391
+ .values({
392
+ id: 'celilo-mgmt',
393
+ name: 'celilo-mgmt',
394
+ version: '1.0.0',
395
+ manifestData: { requires: { system: { zone: 'internal' } } },
396
+ sourcePath: '/tmp/celilo-mgmt',
397
+ state: 'VERIFIED',
398
+ })
399
+ .run();
400
+ db.insert(moduleConfigs)
401
+ .values({
402
+ moduleId: 'celilo-mgmt',
403
+ key: 'hostname',
404
+ value: 'celilo-mgr',
405
+ valueJson: '"celilo-mgr"',
406
+ })
407
+ .run();
408
+
409
+ const recorded = await recordDeployedSystemForModule(
410
+ 'celilo-mgmt',
411
+ { requires: { system: { zone: 'internal' } } } as ModuleManifest,
412
+ { type: 'machine', machineId: 'm-local' },
413
+ db,
414
+ );
415
+
416
+ expect(recorded[0]).toMatchObject({
417
+ zone: 'secure-mgmt',
418
+ ipv4_address: '10.77.20.32',
419
+ });
420
+ });
421
+
321
422
  test('skips an API-only module (no declared systems)', () => {
322
423
  ensureProxmoxService(db);
323
424
  db.insert(modules)