@celilo/cli 0.5.0-alpha.9 → 0.6.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.
- package/package.json +2 -2
- package/src/api-clients/proxmox.test.ts +30 -20
- package/src/api-clients/proxmox.ts +34 -23
- package/src/cli/command-registry.ts +19 -0
- package/src/cli/commands/module-update.test.ts +252 -0
- package/src/cli/commands/module-update.ts +571 -0
- package/src/cli/commands/module-upgrade.test.ts +55 -225
- package/src/cli/commands/module-upgrade.ts +205 -500
- package/src/cli/commands/proxmox-vm-template-build.ts +25 -18
- package/src/cli/commands/system-update.ts +3 -3
- package/src/cli/completion.ts +1 -0
- package/src/cli/index.ts +3 -0
- package/src/hooks/capability-loader.ts +7 -3
- package/src/manifest/schema.ts +3 -1
- package/src/services/deploy-posture.test.ts +106 -0
- package/src/services/deploy-posture.ts +87 -0
- package/src/services/module-subscriptions.test.ts +32 -2
- package/src/services/proxmox-state-recovery.ts +26 -7
- package/src/templates/generator.test.ts +22 -1
- package/src/templates/generator.ts +22 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@aws-sdk/client-s3": "^3.1024.0",
|
|
55
|
-
"@celilo/capabilities": "^0.
|
|
55
|
+
"@celilo/capabilities": "^0.5.0",
|
|
56
56
|
"@celilo/cli-display": "^0.1.9",
|
|
57
57
|
"@celilo/event-bus": "^0.1.7",
|
|
58
58
|
"@clack/prompts": "^1.1.0",
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
findNodeForVmid,
|
|
7
7
|
pollTaskUntilDone,
|
|
8
8
|
selectFreeVmid,
|
|
9
|
-
|
|
9
|
+
selectImportStorage,
|
|
10
10
|
summarizeNodeCapacities,
|
|
11
11
|
} from './proxmox';
|
|
12
12
|
|
|
@@ -137,38 +137,48 @@ describe('filterVmTemplates — extract VM templates from a /nodes/{node}/qemu l
|
|
|
137
137
|
});
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
-
describe('
|
|
141
|
-
test('returns the first active, enabled storage whose content includes
|
|
140
|
+
describe('selectImportStorage — pick a storage that accepts import content', () => {
|
|
141
|
+
test('returns the first active, enabled storage whose content includes import', () => {
|
|
142
142
|
const storages = [
|
|
143
143
|
{ storage: 'local-lvm', content: 'images,rootdir', active: 1, enabled: 1 },
|
|
144
|
-
{ storage: 'local', content: '
|
|
144
|
+
{ storage: 'local', content: 'import,vztmpl,backup', active: 1, enabled: 1 },
|
|
145
145
|
];
|
|
146
|
-
expect(
|
|
146
|
+
expect(selectImportStorage(storages)).toBe('local');
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
-
test('
|
|
149
|
+
test('does NOT pick an iso-only storage (import-from rejects iso content)', () => {
|
|
150
|
+
// Regression guard: the cloud image must land in the import namespace, not
|
|
151
|
+
// iso/, or the VM-create `import-from=` fails with `has wrong type 'iso'`.
|
|
150
152
|
const storages = [
|
|
151
|
-
{ storage: '
|
|
152
|
-
{ storage: '
|
|
153
|
+
{ storage: 'iso-only', content: 'iso,vztmpl,backup', active: 1, enabled: 1 },
|
|
154
|
+
{ storage: 'data', content: 'import,images', active: 1, enabled: 1 },
|
|
153
155
|
];
|
|
154
|
-
expect(
|
|
156
|
+
expect(selectImportStorage(storages)).toBe('data');
|
|
155
157
|
});
|
|
156
158
|
|
|
157
|
-
test('skips an
|
|
159
|
+
test('skips an import-capable storage that is inactive', () => {
|
|
158
160
|
const storages = [
|
|
159
|
-
{ storage: '
|
|
160
|
-
{ storage: 'local', content: '
|
|
161
|
+
{ storage: 'cold', content: 'import', active: 0, enabled: 1 },
|
|
162
|
+
{ storage: 'local', content: 'import', active: 1, enabled: 1 },
|
|
161
163
|
];
|
|
162
|
-
expect(
|
|
164
|
+
expect(selectImportStorage(storages)).toBe('local');
|
|
163
165
|
});
|
|
164
166
|
|
|
165
|
-
test('
|
|
167
|
+
test('skips an import-capable storage that is disabled', () => {
|
|
168
|
+
const storages = [
|
|
169
|
+
{ storage: 'off', content: 'import', active: 1, enabled: 0 },
|
|
170
|
+
{ storage: 'local', content: 'import', active: 1, enabled: 1 },
|
|
171
|
+
];
|
|
172
|
+
expect(selectImportStorage(storages)).toBe('local');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('returns null when no storage accepts import content', () => {
|
|
166
176
|
const storages = [{ storage: 'local-lvm', content: 'images,rootdir', active: 1, enabled: 1 }];
|
|
167
|
-
expect(
|
|
177
|
+
expect(selectImportStorage(storages)).toBeNull();
|
|
168
178
|
});
|
|
169
179
|
|
|
170
180
|
test('returns null for an empty storage list', () => {
|
|
171
|
-
expect(
|
|
181
|
+
expect(selectImportStorage([])).toBeNull();
|
|
172
182
|
});
|
|
173
183
|
});
|
|
174
184
|
|
|
@@ -200,15 +210,15 @@ describe('selectFreeVmid — first free VMID >= minVmid (template hygiene)', ()
|
|
|
200
210
|
});
|
|
201
211
|
});
|
|
202
212
|
|
|
203
|
-
describe('buildCloudImageVolid — volid for a cloud image on
|
|
204
|
-
test('places the filename under the storage
|
|
213
|
+
describe('buildCloudImageVolid — volid for a cloud image on import storage', () => {
|
|
214
|
+
test('places the filename under the storage import/ namespace', () => {
|
|
205
215
|
expect(buildCloudImageVolid('local', 'noble-server-cloudimg-amd64.img')).toBe(
|
|
206
|
-
'local:
|
|
216
|
+
'local:import/noble-server-cloudimg-amd64.img',
|
|
207
217
|
);
|
|
208
218
|
});
|
|
209
219
|
|
|
210
220
|
test('works with an arbitrary storage name', () => {
|
|
211
|
-
expect(buildCloudImageVolid('nas
|
|
221
|
+
expect(buildCloudImageVolid('nas', 'jammy.img')).toBe('nas:import/jammy.img');
|
|
212
222
|
});
|
|
213
223
|
});
|
|
214
224
|
|
|
@@ -890,30 +890,38 @@ export async function listVmTemplates(
|
|
|
890
890
|
return { success: true, data: filterVmTemplates(result.data) };
|
|
891
891
|
}
|
|
892
892
|
|
|
893
|
-
/** Storage row shape (subset) used when picking
|
|
893
|
+
/** Storage row shape (subset) used when picking import-capable storage. */
|
|
894
894
|
type ProxmoxStorageRow = { storage: string; content: string; active: number; enabled: number };
|
|
895
895
|
|
|
896
896
|
/**
|
|
897
|
-
* Pick the first active, enabled storage that accepts `
|
|
897
|
+
* Pick the first active, enabled storage that accepts `import` content. Pure
|
|
898
898
|
* selection logic, split from the network call for testability (Rule 10).
|
|
899
899
|
* Returns the storage name, or null when none qualifies.
|
|
900
|
+
*
|
|
901
|
+
* Must be `import`, NOT `iso`: the cloud image is consumed via the VM-create
|
|
902
|
+
* `import-from=` parameter, which Proxmox only accepts from a source on a
|
|
903
|
+
* storage whose content type is `images` or `import`. A `.img` downloaded to
|
|
904
|
+
* the `iso/` namespace is rejected with `has wrong type 'iso'`. The
|
|
905
|
+
* `download-url` endpoint accepts `content=import` on PVE 8.2+, so we download
|
|
906
|
+
* straight into the import namespace. (PVE 8.2+; node fleet is 9.x.)
|
|
900
907
|
*/
|
|
901
|
-
export function
|
|
902
|
-
const
|
|
903
|
-
return
|
|
908
|
+
export function selectImportStorage(storages: ProxmoxStorageRow[]): string | null {
|
|
909
|
+
const match = storages.find((s) => s.active && s.enabled && s.content.includes('import'));
|
|
910
|
+
return match?.storage ?? null;
|
|
904
911
|
}
|
|
905
912
|
|
|
906
913
|
/**
|
|
907
|
-
* Find a storage on the node that accepts `
|
|
908
|
-
* images). Returns the storage name,
|
|
914
|
+
* Find a storage on the node that accepts `import` content (for downloading
|
|
915
|
+
* cloud images that will be imported as a VM disk). Returns the storage name,
|
|
916
|
+
* or null if none found.
|
|
909
917
|
*/
|
|
910
|
-
export async function
|
|
918
|
+
export async function findImportStorage(
|
|
911
919
|
credentials: ProxmoxCredentials,
|
|
912
920
|
nodeName: string,
|
|
913
921
|
): Promise<ProxmoxResult<string | null>> {
|
|
914
922
|
const result = await listNodeStorage(credentials, nodeName);
|
|
915
923
|
if (!result.success) return result;
|
|
916
|
-
return { success: true, data:
|
|
924
|
+
return { success: true, data: selectImportStorage(result.data) };
|
|
917
925
|
}
|
|
918
926
|
|
|
919
927
|
/**
|
|
@@ -953,20 +961,22 @@ export async function findFreeTemplateVmid(
|
|
|
953
961
|
}
|
|
954
962
|
|
|
955
963
|
/**
|
|
956
|
-
* Download a cloud image URL to
|
|
957
|
-
* status polling. Requires PVE
|
|
964
|
+
* Download a cloud image URL to `import` storage on the node. Returns a UPID
|
|
965
|
+
* for status polling. Requires PVE 8.2+ (`download-url` with `content=import`).
|
|
966
|
+
* The image MUST land in the import namespace (not iso) so the VM-create
|
|
967
|
+
* `import-from=` accepts it — see selectImportStorage.
|
|
958
968
|
*/
|
|
959
969
|
export async function downloadCloudImage(
|
|
960
970
|
credentials: ProxmoxCredentials,
|
|
961
971
|
nodeName: string,
|
|
962
|
-
|
|
972
|
+
importStorage: string,
|
|
963
973
|
url: string,
|
|
964
974
|
filename: string,
|
|
965
975
|
): Promise<ProxmoxResult<string>> {
|
|
966
976
|
return makeProxmoxPost<string>(
|
|
967
977
|
credentials,
|
|
968
|
-
`/nodes/${nodeName}/storage/${
|
|
969
|
-
{ url, filename, content: '
|
|
978
|
+
`/nodes/${nodeName}/storage/${importStorage}/download-url`,
|
|
979
|
+
{ url, filename, content: 'import' },
|
|
970
980
|
);
|
|
971
981
|
}
|
|
972
982
|
|
|
@@ -1003,13 +1013,14 @@ export async function pollTaskUntilDone(
|
|
|
1003
1013
|
}
|
|
1004
1014
|
|
|
1005
1015
|
/**
|
|
1006
|
-
* Build the volid for a cloud image downloaded to
|
|
1016
|
+
* Build the volid for a cloud image downloaded to `import` storage. Pure path
|
|
1007
1017
|
* construction, split out for testability (Rule 10) — mirrors buildTemplatePath.
|
|
1008
|
-
* Proxmox's `download-url` with `content=
|
|
1009
|
-
* storage's `
|
|
1018
|
+
* Proxmox's `download-url` with `content=import` stores the `.img` under the
|
|
1019
|
+
* storage's `import/` namespace, so the volid is `<storage>:import/<filename>`.
|
|
1020
|
+
* This is the namespace the VM-create `import-from=` accepts (iso/ is rejected).
|
|
1010
1021
|
*/
|
|
1011
|
-
export function buildCloudImageVolid(
|
|
1012
|
-
return `${
|
|
1022
|
+
export function buildCloudImageVolid(importStorage: string, filename: string): string {
|
|
1023
|
+
return `${importStorage}:import/${filename}`;
|
|
1013
1024
|
}
|
|
1014
1025
|
|
|
1015
1026
|
export interface CreateVmFromCloudImageParams {
|
|
@@ -1017,8 +1028,8 @@ export interface CreateVmFromCloudImageParams {
|
|
|
1017
1028
|
nodeName: string;
|
|
1018
1029
|
vmid: number;
|
|
1019
1030
|
name: string;
|
|
1020
|
-
/** Volume ID of the downloaded cloud image, e.g. `local:
|
|
1021
|
-
|
|
1031
|
+
/** Volume ID of the downloaded cloud image, e.g. `local:import/noble-server-cloudimg-amd64.img` */
|
|
1032
|
+
imageVolid: string;
|
|
1022
1033
|
/** Storage for the imported VM disk and the cloud-init drive */
|
|
1023
1034
|
diskStorage: string;
|
|
1024
1035
|
}
|
|
@@ -1038,7 +1049,7 @@ export interface CreateVmFromCloudImageParams {
|
|
|
1038
1049
|
export async function createVmFromCloudImage(
|
|
1039
1050
|
params: CreateVmFromCloudImageParams,
|
|
1040
1051
|
): Promise<ProxmoxResult<string>> {
|
|
1041
|
-
const { credentials, nodeName, vmid, name,
|
|
1052
|
+
const { credentials, nodeName, vmid, name, imageVolid, diskStorage } = params;
|
|
1042
1053
|
return makeProxmoxPost<string>(credentials, `/nodes/${nodeName}/qemu`, {
|
|
1043
1054
|
vmid: String(vmid),
|
|
1044
1055
|
name,
|
|
@@ -1050,7 +1061,7 @@ export async function createVmFromCloudImage(
|
|
|
1050
1061
|
serial0: 'socket',
|
|
1051
1062
|
vga: 'serial0',
|
|
1052
1063
|
boot: 'order=scsi0',
|
|
1053
|
-
scsi0: `${diskStorage}:0,import-from=${
|
|
1064
|
+
scsi0: `${diskStorage}:0,import-from=${imageVolid}`,
|
|
1054
1065
|
ide2: `${diskStorage}:cloudinit`,
|
|
1055
1066
|
});
|
|
1056
1067
|
}
|
|
@@ -435,6 +435,25 @@ export const COMMANDS: CommandDef[] = [
|
|
|
435
435
|
},
|
|
436
436
|
],
|
|
437
437
|
},
|
|
438
|
+
{
|
|
439
|
+
name: 'upgrade',
|
|
440
|
+
description:
|
|
441
|
+
'Upgrade a module to its latest registry version: update → backup (posture-gated) → deploy → verify. No id = the CD poll: upgrade every auto_upgrade module with a newer version.',
|
|
442
|
+
args: [
|
|
443
|
+
{
|
|
444
|
+
name: 'id',
|
|
445
|
+
description: 'Module ID (omit to poll all auto_upgrade modules)',
|
|
446
|
+
completion: 'module_ids',
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
flags: [
|
|
450
|
+
{
|
|
451
|
+
name: 'registry',
|
|
452
|
+
description: 'Registry URL (defaults to configured registry)',
|
|
453
|
+
takesValue: true,
|
|
454
|
+
},
|
|
455
|
+
],
|
|
456
|
+
},
|
|
438
457
|
{
|
|
439
458
|
name: 'verify',
|
|
440
459
|
description: 'Verify module integrity (signature + checksums)',
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the version-change classifier used by `module update`'s
|
|
3
|
+
* registry-sweep mode, plus integration tests for `updateOne`'s
|
|
4
|
+
* `displayVersion` / `quiet` opts that drive the cleaner sweep output.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
8
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { eq } from 'drizzle-orm';
|
|
12
|
+
import { type DbClient, getDb } from '../../db/client';
|
|
13
|
+
import { modules } from '../../db/schema';
|
|
14
|
+
import { classifyVersionChange, updateOne } from './module-update';
|
|
15
|
+
|
|
16
|
+
describe('classifyVersionChange', () => {
|
|
17
|
+
test('identical versions are up-to-date', () => {
|
|
18
|
+
expect(classifyVersionChange('1.0.0', '1.0.0')).toBe('up-to-date');
|
|
19
|
+
expect(classifyVersionChange('1.0.0+3', '1.0.0+3')).toBe('up-to-date');
|
|
20
|
+
expect(classifyVersionChange('2.4.7+5', '2.4.7+5')).toBe('up-to-date');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('major bump → breaking', () => {
|
|
24
|
+
expect(classifyVersionChange('1.0.0+3', '2.0.0+1')).toBe('major');
|
|
25
|
+
expect(classifyVersionChange('1.5.9', '2.0.0')).toBe('major');
|
|
26
|
+
// Even a tiny step into the next major counts as breaking; the
|
|
27
|
+
// operator decides whether to take it.
|
|
28
|
+
expect(classifyVersionChange('1.0.0+9', '2.0.0+0')).toBe('major');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('minor bump → non-breaking', () => {
|
|
32
|
+
expect(classifyVersionChange('1.0.0+3', '1.1.0+1')).toBe('minor');
|
|
33
|
+
expect(classifyVersionChange('2.5.0', '2.6.0')).toBe('minor');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('patch bump → non-breaking', () => {
|
|
37
|
+
expect(classifyVersionChange('1.0.0+3', '1.0.1+1')).toBe('patch');
|
|
38
|
+
expect(classifyVersionChange('2.5.7', '2.5.8')).toBe('patch');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('revision-only bump (+N) → patch (non-breaking)', () => {
|
|
42
|
+
// Exact case the user is hitting today: same code, fresh publish.
|
|
43
|
+
expect(classifyVersionChange('1.0.0+3', '1.0.0+4')).toBe('patch');
|
|
44
|
+
expect(classifyVersionChange('namecheap-1.0.0', 'namecheap-1.0.0+5')).not.toBe('major');
|
|
45
|
+
expect(classifyVersionChange('3.1.0+0', '3.1.0+9')).toBe('patch');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('installed ahead of registry → ahead (skip silently)', () => {
|
|
49
|
+
// Operator pushed locally without publishing — registry is stale.
|
|
50
|
+
expect(classifyVersionChange('2.0.0+1', '1.5.0+9')).toBe('ahead');
|
|
51
|
+
expect(classifyVersionChange('1.0.1', '1.0.0')).toBe('ahead');
|
|
52
|
+
expect(classifyVersionChange('1.0.0+5', '1.0.0+3')).toBe('ahead');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('tolerates `v` / `=` prefixes', () => {
|
|
56
|
+
expect(classifyVersionChange('v1.0.0+3', 'v1.0.0+4')).toBe('patch');
|
|
57
|
+
expect(classifyVersionChange('=1.0.0', '=1.1.0')).toBe('minor');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('missing patch / revision segments default to 0', () => {
|
|
61
|
+
expect(classifyVersionChange('1.0', '1.0.1')).toBe('patch');
|
|
62
|
+
expect(classifyVersionChange('1.0', '2.0')).toBe('major');
|
|
63
|
+
expect(classifyVersionChange('1.0.0', '1.0.0+1')).toBe('patch');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('updateOne — displayVersion and quiet', () => {
|
|
68
|
+
let tempDir: string;
|
|
69
|
+
let srcDir: string;
|
|
70
|
+
let installedDir: string;
|
|
71
|
+
let db: DbClient;
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
tempDir = mkdtempSync(join(tmpdir(), 'celilo-upgrade-'));
|
|
75
|
+
process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
|
|
76
|
+
process.env.CELILO_ORIGINAL_CWD = tempDir;
|
|
77
|
+
|
|
78
|
+
// Pre-installed module landing zone (where files get copied to).
|
|
79
|
+
installedDir = join(tempDir, 'installed', 'testmod');
|
|
80
|
+
mkdirSync(installedDir, { recursive: true });
|
|
81
|
+
|
|
82
|
+
// "New" source dir we're upgrading from (mimics a registry extract).
|
|
83
|
+
srcDir = join(tempDir, 'src');
|
|
84
|
+
mkdirSync(srcDir, { recursive: true });
|
|
85
|
+
writeFileSync(
|
|
86
|
+
join(srcDir, 'manifest.yml'),
|
|
87
|
+
`celilo_contract: "1.0"
|
|
88
|
+
id: testmod
|
|
89
|
+
name: Test Module
|
|
90
|
+
version: 1.0.0
|
|
91
|
+
description: fixture
|
|
92
|
+
`,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
db = getDb();
|
|
96
|
+
// Seed the modules table with the "currently installed" record.
|
|
97
|
+
// We deliberately put a registry-style version string in here so
|
|
98
|
+
// displayVersion-less upgrades preserve the previousVersion field.
|
|
99
|
+
db.insert(modules)
|
|
100
|
+
.values({
|
|
101
|
+
id: 'testmod',
|
|
102
|
+
name: 'Test Module',
|
|
103
|
+
sourcePath: installedDir,
|
|
104
|
+
version: '1.0.0+5',
|
|
105
|
+
manifestData: {
|
|
106
|
+
celilo_contract: '1.0',
|
|
107
|
+
id: 'testmod',
|
|
108
|
+
name: 'Test Module',
|
|
109
|
+
version: '1.0.0',
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
.run();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
117
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
118
|
+
process.env.CELILO_ORIGINAL_CWD = undefined;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('returns previousVersion + newVersion in the success outcome', async () => {
|
|
122
|
+
const result = await updateOne(srcDir, db, {}, { quiet: true });
|
|
123
|
+
expect(result.status).toBe('success');
|
|
124
|
+
if (result.status !== 'success') return; // narrow for ts
|
|
125
|
+
expect(result.moduleId).toBe('testmod');
|
|
126
|
+
expect(result.previousVersion).toBe('1.0.0+5');
|
|
127
|
+
// No displayVersion supplied → falls back to the new manifest's
|
|
128
|
+
// semver core (no +N visible to the operator from a path upgrade).
|
|
129
|
+
expect(result.newVersion).toBe('1.0.0');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('displayVersion is persisted to modules.version (so +N survives)', async () => {
|
|
133
|
+
// The bug case: registry says 1.0.0+6, manifest says 1.0.0. Without
|
|
134
|
+
// displayVersion, the +6 was dropped on the floor and `module list`
|
|
135
|
+
// rolled back to "1.0.0", masking the actual installed revision.
|
|
136
|
+
const result = await updateOne(srcDir, db, {}, { quiet: true, displayVersion: '1.0.0+6' });
|
|
137
|
+
expect(result.status).toBe('success');
|
|
138
|
+
if (result.status !== 'success') return;
|
|
139
|
+
expect(result.newVersion).toBe('1.0.0+6');
|
|
140
|
+
|
|
141
|
+
const row = db.select().from(modules).all()[0];
|
|
142
|
+
expect(row.version).toBe('1.0.0+6');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('quiet=true suppresses the per-call log lines (caller renders its own)', async () => {
|
|
146
|
+
// We can't easily intercept @clack's log output without adding test
|
|
147
|
+
// hooks, so instead we exercise that the call simply succeeds and
|
|
148
|
+
// returns the structured outcome — the sweep relies on this to
|
|
149
|
+
// render its own output without duplicates. A non-quiet call
|
|
150
|
+
// exercises the same code path with the log lines enabled; both
|
|
151
|
+
// return the same shape.
|
|
152
|
+
const quietResult = await updateOne(srcDir, db, {}, { quiet: true });
|
|
153
|
+
expect(quietResult.status).toBe('success');
|
|
154
|
+
if (quietResult.status !== 'success') return;
|
|
155
|
+
expect(quietResult.newVersion).toBe('1.0.0');
|
|
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 updateOne(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
|
+
});
|
|
215
|
+
|
|
216
|
+
// Regression for ISS-0091: `module update` used to skip event-bus
|
|
217
|
+
// subscription registration (only `module import` did it), so a
|
|
218
|
+
// refreshed module silently lost its reconcile subscriptions — found
|
|
219
|
+
// live when caddy's reconcile_routes subscription vanished after a
|
|
220
|
+
// path-based update.
|
|
221
|
+
test('registers the new manifest subscriptions on the bus (ISS-0091)', async () => {
|
|
222
|
+
process.env.EVENT_BUS_DB = join(tempDir, 'events.db');
|
|
223
|
+
try {
|
|
224
|
+
writeFileSync(
|
|
225
|
+
join(srcDir, 'manifest.yml'),
|
|
226
|
+
`celilo_contract: "1.0"
|
|
227
|
+
id: testmod
|
|
228
|
+
name: Test Module
|
|
229
|
+
version: 1.1.0
|
|
230
|
+
description: fixture with subscriptions
|
|
231
|
+
subscriptions:
|
|
232
|
+
- name: testmod-tick
|
|
233
|
+
pattern: timer.tick.15m
|
|
234
|
+
handler: "true"
|
|
235
|
+
`,
|
|
236
|
+
);
|
|
237
|
+
const result = await updateOne(srcDir, db, {}, { quiet: true });
|
|
238
|
+
expect(result.status).toBe('success');
|
|
239
|
+
|
|
240
|
+
const { openBus, defineEvents } = await import('@celilo/event-bus');
|
|
241
|
+
const bus = openBus({ dbPath: join(tempDir, 'events.db'), events: defineEvents({}) });
|
|
242
|
+
try {
|
|
243
|
+
// Subscriber names are scoped `<module-id>.<sub-name>` on the bus.
|
|
244
|
+
expect(bus.getSubscriberByName('testmod.testmod-tick')).not.toBeNull();
|
|
245
|
+
} finally {
|
|
246
|
+
bus.close();
|
|
247
|
+
}
|
|
248
|
+
} finally {
|
|
249
|
+
process.env.EVENT_BUS_DB = undefined;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
});
|