@celilo/cli 0.3.29 → 0.3.30

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.3.29",
3
+ "version": "0.3.30",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
8
8
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
+ import { eq } from 'drizzle-orm';
11
12
  import { type DbClient, getDb } from '../../db/client';
12
13
  import { modules } from '../../db/schema';
13
14
  import { classifyVersionChange, upgradeOne } from './module-upgrade';
@@ -153,4 +154,62 @@ description: fixture
153
154
  if (quietResult.status !== 'success') return;
154
155
  expect(quietResult.newVersion).toBe('1.0.0');
155
156
  });
157
+
158
+ // Regression for the namecheap stale-findings problem: the upgrade
159
+ // path MUST overwrite manifestData with the new manifest — otherwise
160
+ // any subsequent audit (or any code path that reads from the DB)
161
+ // sees the OLD manifest's required vars even after the operator
162
+ // upgraded to a version that removed them. The user hit this on
163
+ // celilo-mgmt: namecheap@3.1.1+6 dropped the `domains` variable, but
164
+ // post-upgrade the audit still complained "required config 'domains'
165
+ // is not set" because the DB's manifestData hadn't been refreshed.
166
+ test('persists new manifest to modules.manifestData (not just .version)', async () => {
167
+ // Write a brand-new manifest.yml in srcDir that REMOVES a
168
+ // variable the old DB record had. After upgrade, the modules
169
+ // row's manifestData should reflect the removal.
170
+ writeFileSync(
171
+ join(srcDir, 'manifest.yml'),
172
+ `celilo_contract: "1.0"
173
+ id: testmod
174
+ name: Test Module Renamed
175
+ version: 2.0.0
176
+ description: fixture v2
177
+ variables:
178
+ owns: []
179
+ imports: []
180
+ `,
181
+ );
182
+ // Simulate the DB starting in a state where manifestData has a
183
+ // `variables.owns` array — the namecheap-3.1.0 → 3.1.1 case.
184
+ db.update(modules)
185
+ .set({
186
+ manifestData: {
187
+ celilo_contract: '1.0',
188
+ id: 'testmod',
189
+ name: 'Test Module',
190
+ version: '1.0.0',
191
+ variables: { owns: [{ name: 'domains', type: 'array', required: true }], imports: [] },
192
+ },
193
+ })
194
+ .where(eq(modules.id, 'testmod'))
195
+ .run();
196
+
197
+ const result = await upgradeOne(srcDir, db, {}, { quiet: true, displayVersion: '2.0.0+1' });
198
+ expect(result.status).toBe('success');
199
+
200
+ const row = db.select().from(modules).all()[0];
201
+ // version field updates (this part already worked):
202
+ expect(row.version).toBe('2.0.0+1');
203
+ expect(row.name).toBe('Test Module Renamed');
204
+ // manifestData reflects the NEW manifest — the dropped variable
205
+ // is gone. This is the regression assertion.
206
+ const manifest = row.manifestData as {
207
+ version: string;
208
+ name: string;
209
+ variables?: { owns: unknown[] };
210
+ };
211
+ expect(manifest.version).toBe('2.0.0');
212
+ expect(manifest.name).toBe('Test Module Renamed');
213
+ expect(manifest.variables?.owns ?? []).toEqual([]);
214
+ });
156
215
  });
@@ -685,6 +685,21 @@ export async function handleSystemUpdate(
685
685
  onlyModule,
686
686
  });
687
687
 
688
+ // The audit baked into `result.audit` ran BEFORE the orchestrator
689
+ // upgraded any modules. Its findings are now stale for whatever
690
+ // we just upgraded — `module_versions` drift, `module_configs`
691
+ // referencing the OLD manifest's required vars, `capability_abi`
692
+ // checking pre-upgrade providers. Re-query the modules table and
693
+ // re-run the audit so the displayed findings reflect post-upgrade
694
+ // reality. Only do this on a successful run (a partial-failure
695
+ // run keeps its original audit so the operator sees what the
696
+ // orchestrator was reacting to).
697
+ const successfulModuleSteps = result.modules.filter((m) => m.step === 'done');
698
+ if (result.ok && successfulModuleSteps.length > 0) {
699
+ const refreshedAudit = await runAudit(rebuildAuditDepsForRerun(auditDeps, db));
700
+ result.audit = refreshedAudit;
701
+ }
702
+
688
703
  if (json) {
689
704
  if (result.ok) {
690
705
  return { success: true, message: JSON.stringify(result, null, 2), rawOutput: true };
@@ -700,3 +715,84 @@ export async function handleSystemUpdate(
700
715
  error: `${formatResult(result)}\n\none or more modules failed; see output above`,
701
716
  };
702
717
  }
718
+
719
+ type AuditDeps = Parameters<typeof runAudit>[0];
720
+
721
+ /**
722
+ * Build a fresh AuditDeps object whose module-state-dependent
723
+ * sub-deps are re-queried from the DB. The non-module deps
724
+ * (cliVersion fetcher, migrations, terraform plan runner, registry
725
+ * fetcher, etc.) are reused from the original deps because they
726
+ * don't change during a single system-update run.
727
+ *
728
+ * Used by the post-orchestrator re-audit so displayed findings
729
+ * reflect post-upgrade reality (e.g., a module_versions drift
730
+ * finding for a module we just upgraded is no longer reported).
731
+ */
732
+ export function rebuildAuditDepsForRerun(
733
+ original: AuditDeps,
734
+ db: ReturnType<typeof getDb>,
735
+ ): AuditDeps {
736
+ const installed = db.select().from(modules).all();
737
+ const upgradeEligibleStates = new Set(['INSTALLED', 'VERIFIED', 'IMPORTED']);
738
+ const upgradable = installed.filter((m) => upgradeEligibleStates.has(m.state));
739
+
740
+ const allConfigs = db.select().from(moduleConfigsTbl).all();
741
+ const configsByModule = new Map<string, Record<string, unknown>>();
742
+ for (const c of allConfigs) {
743
+ const m = configsByModule.get(c.moduleId) ?? {};
744
+ m[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
745
+ configsByModule.set(c.moduleId, m);
746
+ }
747
+
748
+ // Backup recency is unchanged across an orchestrator run (only
749
+ // celilo-DB snapshots happen, not per-module backup writes), so
750
+ // we look up each module's prior lastSuccessfulBackupAt by id
751
+ // rather than re-querying the backups table.
752
+ const priorBackupByModule = new Map<string, number | null>();
753
+ for (const b of original.backups.modules) {
754
+ priorBackupByModule.set(b.id, b.lastSuccessfulBackupAt);
755
+ }
756
+
757
+ return {
758
+ ...original,
759
+ capabilityAbi: {
760
+ modules: upgradable.map((m) => ({
761
+ id: m.id,
762
+ state: m.state,
763
+ manifest: m.manifestData as ModuleManifest,
764
+ })),
765
+ },
766
+ moduleVersions: {
767
+ ...original.moduleVersions,
768
+ installed: upgradable.map((m) => ({ id: m.id, version: m.version })),
769
+ },
770
+ moduleConfigs: {
771
+ modules: upgradable.map((m) => ({
772
+ id: m.id,
773
+ state: m.state,
774
+ manifest: m.manifestData as ModuleManifest,
775
+ configs: configsByModule.get(m.id) ?? {},
776
+ })),
777
+ },
778
+ backups: {
779
+ ...original.backups,
780
+ modules: upgradable.map((m) => ({
781
+ id: m.id,
782
+ state: m.state,
783
+ manifest: m.manifestData as ModuleManifest,
784
+ lastSuccessfulBackupAt: priorBackupByModule.get(m.id) ?? null,
785
+ })),
786
+ },
787
+ undeployedModules: {
788
+ modules: installed.map((m) => ({ id: m.id, state: m.state })),
789
+ },
790
+ unconfiguredModules: {
791
+ modules: installed.map((m) => ({
792
+ id: m.id,
793
+ state: m.state,
794
+ configCount: Object.keys(configsByModule.get(m.id) ?? {}).length,
795
+ })),
796
+ },
797
+ };
798
+ }