@celilo/cli 0.3.16 → 0.3.17
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 +1 -1
- package/src/api-clients/proxmox.ts +77 -45
- package/src/cli/command-registry.ts +12 -12
- package/src/cli/commands/completion.ts +12 -11
- package/src/cli/commands/module-check.ts +158 -0
- package/src/cli/commands/module-import.ts +5 -5
- package/src/cli/commands/module-publish.test.ts +3 -90
- package/src/cli/commands/module-publish.ts +14 -118
- package/src/cli/commands/proxmox-template-selection.test.ts +150 -0
- package/src/cli/commands/proxmox-template-selection.ts +258 -0
- package/src/cli/commands/service-add-proxmox.ts +49 -127
- package/src/cli/commands/service-reconfigure.ts +36 -79
- package/src/cli/commands/service-verify.ts +20 -79
- package/src/cli/commands/{doctor.test.ts → system-doctor.test.ts} +1 -1
- package/src/cli/commands/{doctor.ts → system-doctor.ts} +93 -18
- package/src/cli/completion.ts +29 -2
- package/src/cli/index.ts +16 -7
- package/src/module/import.ts +4 -2
- package/src/registry/client.ts +14 -1
- package/src/services/module-deploy.ts +19 -1
- package/src/services/module-validator/capability-versions.test.ts +90 -0
- package/src/services/module-validator/capability-versions.ts +115 -0
- package/src/services/module-validator/contract-version.test.ts +24 -0
- package/src/services/module-validator/contract-version.ts +69 -0
- package/src/services/module-validator/git-hygiene.test.ts +141 -0
- package/src/services/module-validator/git-hygiene.ts +144 -0
- package/src/services/module-validator/index.test.ts +67 -0
- package/src/services/module-validator/index.ts +74 -0
- package/src/services/module-validator/manifest-schema.ts +42 -0
- package/src/services/module-validator/types.ts +43 -0
- package/src/services/module-validator/typescript-build.test.ts +58 -0
- package/src/services/module-validator/typescript-build.ts +115 -0
- package/src/services/module-validator/workspace-deps.test.ts +137 -0
- package/src/services/module-validator/workspace-deps.ts +187 -0
- package/src/system/prereqs.test.ts +374 -0
- package/src/system/prereqs.ts +377 -0
|
@@ -35,16 +35,10 @@
|
|
|
35
35
|
* timestamp / CLI version / optional --message.
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
-
import { spawnSync } from 'node:child_process';
|
|
39
38
|
import { rm } from 'node:fs/promises';
|
|
40
39
|
import { readFile } from 'node:fs/promises';
|
|
41
40
|
import { tmpdir } from 'node:os';
|
|
42
41
|
import { join, resolve } from 'node:path';
|
|
43
|
-
import {
|
|
44
|
-
CAPABILITY_CONTRACT_VERSIONS,
|
|
45
|
-
type KnownCapabilityName,
|
|
46
|
-
compareProviderToRuntime,
|
|
47
|
-
} from '@celilo/capabilities';
|
|
48
42
|
import { parse as parseYaml } from 'yaml';
|
|
49
43
|
import { buildModule } from '../../module/packaging/build';
|
|
50
44
|
import {
|
|
@@ -55,122 +49,18 @@ import {
|
|
|
55
49
|
} from '../../module/packaging/release-metadata';
|
|
56
50
|
import { RegistryClient } from '../../registry/client';
|
|
57
51
|
import { CrossModuleDataManager } from '../../services/cross-module-data-manager';
|
|
52
|
+
import {
|
|
53
|
+
type ManifestForCapabilityCheck,
|
|
54
|
+
validateCapabilityVersions,
|
|
55
|
+
} from '../../services/module-validator/capability-versions';
|
|
56
|
+
import { checkModuleStale } from '../../services/module-validator/git-hygiene';
|
|
58
57
|
import { getFlag, hasFlag } from '../parser';
|
|
59
58
|
import type { CommandResult } from '../types';
|
|
60
59
|
|
|
61
|
-
interface
|
|
62
|
-
name: string;
|
|
63
|
-
version: string;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
interface ManifestForPublish {
|
|
60
|
+
interface ManifestForPublish extends ManifestForCapabilityCheck {
|
|
67
61
|
id: string;
|
|
68
62
|
version: string;
|
|
69
|
-
|
|
70
|
-
requires?: { capabilities?: ManifestCapabilityClaim[] };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function isKnownCapability(name: string): name is KnownCapabilityName {
|
|
74
|
-
return name in CAPABILITY_CONTRACT_VERSIONS;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Strict-publish: every `provides[X].version` must match the framework's
|
|
79
|
-
* runtime registry; every `requires[X].version` must be at most the
|
|
80
|
-
* framework's runtime version (consumers can require older minors of
|
|
81
|
-
* still-supported majors).
|
|
82
|
-
*
|
|
83
|
-
* Exported for testing.
|
|
84
|
-
*/
|
|
85
|
-
export function validateCapabilityVersions(manifest: ManifestForPublish): string[] {
|
|
86
|
-
const errors: string[] = [];
|
|
87
|
-
|
|
88
|
-
for (const p of manifest.provides?.capabilities ?? []) {
|
|
89
|
-
if (!isKnownCapability(p.name)) continue;
|
|
90
|
-
const runtime = CAPABILITY_CONTRACT_VERSIONS[p.name];
|
|
91
|
-
const r = compareProviderToRuntime(p.version, runtime);
|
|
92
|
-
if (!r.compatible) {
|
|
93
|
-
errors.push(
|
|
94
|
-
`provides[${p.name}].version is ${p.version} but framework registry is ${runtime} ` +
|
|
95
|
-
`(${r.reason}). Update the manifest, then retry.`,
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
for (const need of manifest.requires?.capabilities ?? []) {
|
|
101
|
-
if (!isKnownCapability(need.name)) continue;
|
|
102
|
-
const runtime = CAPABILITY_CONTRACT_VERSIONS[need.name];
|
|
103
|
-
// Treat the runtime as the de-facto provider for publish-time validation:
|
|
104
|
-
// if a consumer's manifest claims it requires a contract newer than the
|
|
105
|
-
// framework even has registered, no in-tree provider can satisfy it.
|
|
106
|
-
const r = compareProviderToRuntime(need.version, runtime);
|
|
107
|
-
if (!r.compatible && r.reason === 'major_mismatch_higher') {
|
|
108
|
-
errors.push(
|
|
109
|
-
`requires[${need.name}].version is ${need.version} but framework registry is ${runtime}. Bump the framework before publishing, or lower the manifest version.`,
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return errors;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Look up the SHA of the last commit touching the given pathspecs. Returns
|
|
119
|
-
* null on any git failure (no repo, never-committed file, etc.) — callers
|
|
120
|
-
* treat that as "can't determine, skip the check."
|
|
121
|
-
*/
|
|
122
|
-
function lastCommitTouching(pathspec: string[]): string | null {
|
|
123
|
-
const r = spawnSync('git', ['log', '-1', '--format=%H', '--', ...pathspec], {
|
|
124
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
125
|
-
encoding: 'utf-8',
|
|
126
|
-
});
|
|
127
|
-
if (r.status !== 0) return null;
|
|
128
|
-
const sha = r.stdout.trim();
|
|
129
|
-
return sha || null;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function isAncestor(maybeAncestor: string, descendant: string): boolean {
|
|
133
|
-
const r = spawnSync('git', ['merge-base', '--is-ancestor', maybeAncestor, descendant], {
|
|
134
|
-
stdio: 'ignore',
|
|
135
|
-
});
|
|
136
|
-
return r.status === 0;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export interface StalenessIssue {
|
|
140
|
-
moduleDir: string;
|
|
141
|
-
lastSrcCommit: string;
|
|
142
|
-
lastManifestCommit: string;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Detect "I edited module src but forgot to bump (or touch) manifest.yml."
|
|
147
|
-
*
|
|
148
|
-
* Returns null when the manifest is the most recently-touched file in the
|
|
149
|
-
* dir (or when neither side has any commit history — e.g. brand-new module
|
|
150
|
-
* not yet committed). Returns a StalenessIssue when src has commits AFTER
|
|
151
|
-
* the last manifest.yml change — the operator must bump the manifest
|
|
152
|
-
* (semver change → reset +N to 1) or just touch it (release-only change →
|
|
153
|
-
* auto-bump +N), then re-publish.
|
|
154
|
-
*
|
|
155
|
-
* Exported for testing.
|
|
156
|
-
*/
|
|
157
|
-
export function checkModuleStale(moduleDir: string): StalenessIssue | null {
|
|
158
|
-
const manifestPath = join(moduleDir, 'manifest.yml');
|
|
159
|
-
const lastManifest = lastCommitTouching([manifestPath]);
|
|
160
|
-
// Excluding manifest.yml from the "src" pathspec is the whole point — we
|
|
161
|
-
// want to know if anything ELSE in the dir moved past it. node_modules and
|
|
162
|
-
// common build outputs are gitignored already, but be explicit defensively.
|
|
163
|
-
const lastSrc = lastCommitTouching([
|
|
164
|
-
moduleDir,
|
|
165
|
-
`:(exclude)${manifestPath}`,
|
|
166
|
-
`:(exclude)${moduleDir}/node_modules`,
|
|
167
|
-
`:(exclude)${moduleDir}/dist`,
|
|
168
|
-
]);
|
|
169
|
-
if (!lastManifest || !lastSrc) return null;
|
|
170
|
-
if (lastSrc === lastManifest) return null; // same commit changed both
|
|
171
|
-
if (!isAncestor(lastManifest, lastSrc)) return null; // manifest moved last
|
|
172
|
-
|
|
173
|
-
return { moduleDir, lastSrcCommit: lastSrc, lastManifestCommit: lastManifest };
|
|
63
|
+
description?: string;
|
|
174
64
|
}
|
|
175
65
|
|
|
176
66
|
/**
|
|
@@ -378,7 +268,13 @@ export async function publishOneModule(
|
|
|
378
268
|
const client = new RegistryClient(opts.registryUrl || undefined);
|
|
379
269
|
try {
|
|
380
270
|
console.log(`Publishing ${name}@${version} to ${client.baseUrl}...`);
|
|
381
|
-
await client.publish({
|
|
271
|
+
await client.publish({
|
|
272
|
+
name,
|
|
273
|
+
version,
|
|
274
|
+
netappPath: buildResult.packagePath,
|
|
275
|
+
token: opts.token,
|
|
276
|
+
description: manifest.description?.trim() || undefined,
|
|
277
|
+
});
|
|
382
278
|
} catch (err) {
|
|
383
279
|
return {
|
|
384
280
|
moduleDir,
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ProxmoxAppliance } from '../../api-clients/proxmox';
|
|
3
|
+
import { buildUbuntuOptions, pickInitialTemplate } from './proxmox-template-selection';
|
|
4
|
+
|
|
5
|
+
// Snapshot of `pveam available --section system` filtered to Ubuntu, taken
|
|
6
|
+
// 2026-05-08. The point of these tests is to lock in the catalog-driven
|
|
7
|
+
// behavior that replaced the hardcoded `-1` revision URL.
|
|
8
|
+
const catalog: ProxmoxAppliance[] = [
|
|
9
|
+
{
|
|
10
|
+
template: 'ubuntu-22.04-standard_22.04-1_amd64.tar.zst',
|
|
11
|
+
package: 'ubuntu-22.04-standard',
|
|
12
|
+
version: '22.04-1',
|
|
13
|
+
section: 'system',
|
|
14
|
+
os: 'ubuntu',
|
|
15
|
+
type: 'lxc',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
template: 'ubuntu-24.04-standard_24.04-2_amd64.tar.zst',
|
|
19
|
+
package: 'ubuntu-24.04-standard',
|
|
20
|
+
version: '24.04-2',
|
|
21
|
+
section: 'system',
|
|
22
|
+
os: 'ubuntu',
|
|
23
|
+
type: 'lxc',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
template: 'ubuntu-24.10-standard_24.10-1_amd64.tar.zst',
|
|
27
|
+
package: 'ubuntu-24.10-standard',
|
|
28
|
+
version: '24.10-1',
|
|
29
|
+
section: 'system',
|
|
30
|
+
os: 'ubuntu',
|
|
31
|
+
type: 'lxc',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
template: 'ubuntu-25.04-standard_25.04-1.1_amd64.tar.zst',
|
|
35
|
+
package: 'ubuntu-25.04-standard',
|
|
36
|
+
version: '25.04-1.1',
|
|
37
|
+
section: 'system',
|
|
38
|
+
os: 'ubuntu',
|
|
39
|
+
type: 'lxc',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
template: 'ubuntu-26.04-standard_26.04-1_amd64.tar.zst',
|
|
43
|
+
package: 'ubuntu-26.04-standard',
|
|
44
|
+
version: '26.04-1',
|
|
45
|
+
section: 'system',
|
|
46
|
+
os: 'ubuntu',
|
|
47
|
+
type: 'lxc',
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
describe('buildUbuntuOptions', () => {
|
|
52
|
+
test('returns Ubuntu options sorted newest-first', () => {
|
|
53
|
+
const options = buildUbuntuOptions(catalog);
|
|
54
|
+
expect(options.map((o) => o.appliance.package)).toEqual([
|
|
55
|
+
'ubuntu-26.04-standard',
|
|
56
|
+
'ubuntu-25.04-standard',
|
|
57
|
+
'ubuntu-24.10-standard',
|
|
58
|
+
'ubuntu-24.04-standard',
|
|
59
|
+
'ubuntu-22.04-standard',
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('flags LTS releases correctly', () => {
|
|
64
|
+
const options = buildUbuntuOptions(catalog);
|
|
65
|
+
const ltsByPackage = Object.fromEntries(options.map((o) => [o.appliance.package, o.isLts]));
|
|
66
|
+
expect(ltsByPackage).toEqual({
|
|
67
|
+
'ubuntu-22.04-standard': true,
|
|
68
|
+
'ubuntu-24.04-standard': true,
|
|
69
|
+
'ubuntu-24.10-standard': false,
|
|
70
|
+
'ubuntu-25.04-standard': false,
|
|
71
|
+
'ubuntu-26.04-standard': true,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('drops non-Ubuntu and non-system entries', () => {
|
|
76
|
+
const mixed: ProxmoxAppliance[] = [
|
|
77
|
+
...catalog,
|
|
78
|
+
{
|
|
79
|
+
template: 'debian-12-standard_12.7-1_amd64.tar.zst',
|
|
80
|
+
package: 'debian-12-standard',
|
|
81
|
+
version: '12.7-1',
|
|
82
|
+
section: 'system',
|
|
83
|
+
os: 'debian',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
template: 'ubuntu-24.04-special_24.04-1_amd64.tar.zst',
|
|
87
|
+
package: 'ubuntu-24.04-standard',
|
|
88
|
+
version: '24.04-1',
|
|
89
|
+
section: 'turnkeylinux',
|
|
90
|
+
os: 'ubuntu',
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
const options = buildUbuntuOptions(mixed);
|
|
94
|
+
expect(options).toHaveLength(catalog.length);
|
|
95
|
+
expect(options.every((o) => (o.appliance.section ?? 'system') === 'system')).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('returns empty array when nothing matches', () => {
|
|
99
|
+
expect(buildUbuntuOptions([])).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('pickInitialTemplate', () => {
|
|
104
|
+
const options = buildUbuntuOptions(catalog);
|
|
105
|
+
|
|
106
|
+
test('defaults to newest LTS when no current template given', () => {
|
|
107
|
+
expect(pickInitialTemplate(options)).toBe('ubuntu-26.04-standard_26.04-1_amd64.tar.zst');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('exact match wins when revision still on mirror', () => {
|
|
111
|
+
expect(pickInitialTemplate(options, 'ubuntu-22.04-standard_22.04-1_amd64.tar.zst')).toBe(
|
|
112
|
+
'ubuntu-22.04-standard_22.04-1_amd64.tar.zst',
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('falls back to family match when revision has been refreshed', () => {
|
|
117
|
+
// The case that motivated the refactor: user has stale -1 cached, mirror has -2.
|
|
118
|
+
expect(pickInitialTemplate(options, 'ubuntu-24.04-standard_24.04-1_amd64.tar.zst')).toBe(
|
|
119
|
+
'ubuntu-24.04-standard_24.04-2_amd64.tar.zst',
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('falls through to newest LTS when current does not match any family', () => {
|
|
124
|
+
expect(pickInitialTemplate(options, 'ubuntu-18.04-standard_18.04-1_amd64.tar.zst')).toBe(
|
|
125
|
+
'ubuntu-26.04-standard_26.04-1_amd64.tar.zst',
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('returns undefined for empty options', () => {
|
|
130
|
+
expect(pickInitialTemplate([])).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('falls back to newest entry when no LTS in catalog', () => {
|
|
134
|
+
const noLts = buildUbuntuOptions([
|
|
135
|
+
{
|
|
136
|
+
template: 'ubuntu-25.04-standard_25.04-1.1_amd64.tar.zst',
|
|
137
|
+
package: 'ubuntu-25.04-standard',
|
|
138
|
+
version: '25.04-1.1',
|
|
139
|
+
section: 'system',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
template: 'ubuntu-24.10-standard_24.10-1_amd64.tar.zst',
|
|
143
|
+
package: 'ubuntu-24.10-standard',
|
|
144
|
+
version: '24.10-1',
|
|
145
|
+
section: 'system',
|
|
146
|
+
},
|
|
147
|
+
]);
|
|
148
|
+
expect(pickInitialTemplate(noLts)).toBe('ubuntu-25.04-standard_25.04-1.1_amd64.tar.zst');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxmox template selection — shared between `service add proxmox` and
|
|
3
|
+
* `service reconfigure`. Queries Proxmox's live appliance catalog
|
|
4
|
+
* (`pveam available`) and prompts the user to pick an Ubuntu LXC template by
|
|
5
|
+
* canonical name.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: Proxmox bumps template revisions over time
|
|
8
|
+
* (ubuntu-24.04 went from -1 to -2, 25.04 ships as -1.1) and an older home-lab
|
|
9
|
+
* deployment guide naming `-1` produces 404s on the mirror. Using the catalog
|
|
10
|
+
* means the canonical filename always matches what `pveam download` accepts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as p from '@clack/prompts';
|
|
14
|
+
import {
|
|
15
|
+
type ProxmoxAppliance,
|
|
16
|
+
type ProxmoxCredentials,
|
|
17
|
+
checkTaskStatus,
|
|
18
|
+
downloadAppliance,
|
|
19
|
+
listAvailableAppliances,
|
|
20
|
+
} from '../../api-clients/proxmox';
|
|
21
|
+
import { FuelGauge } from '../fuel-gauge';
|
|
22
|
+
import { promptText } from '../prompts';
|
|
23
|
+
import { validateRequired } from '../validators';
|
|
24
|
+
|
|
25
|
+
export interface UbuntuTemplateChoice {
|
|
26
|
+
/** Canonical filename, e.g. "ubuntu-24.04-standard_24.04-2_amd64.tar.zst" */
|
|
27
|
+
template: string;
|
|
28
|
+
/**
|
|
29
|
+
* 'catalog' = filename came from Proxmox's aplinfo, so it's known good for
|
|
30
|
+
* downloadAppliance. 'manual' = user typed it because aplinfo was unavailable;
|
|
31
|
+
* caller should expect they'll need to `pveam download` themselves.
|
|
32
|
+
*/
|
|
33
|
+
source: 'catalog' | 'manual';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type TemplateSelectionResult =
|
|
37
|
+
| { kind: 'selected'; choice: UbuntuTemplateChoice }
|
|
38
|
+
| { kind: 'cancelled' }
|
|
39
|
+
| { kind: 'error'; message: string };
|
|
40
|
+
|
|
41
|
+
interface ParsedUbuntu {
|
|
42
|
+
appliance: ProxmoxAppliance;
|
|
43
|
+
major: number;
|
|
44
|
+
minor: number;
|
|
45
|
+
isLts: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseUbuntuPackage(appliance: ProxmoxAppliance): ParsedUbuntu | null {
|
|
49
|
+
const match = appliance.package.match(/^ubuntu-(\d+)\.(\d+)-standard$/);
|
|
50
|
+
if (!match) return null;
|
|
51
|
+
const major = Number(match[1]);
|
|
52
|
+
const minor = Number(match[2]);
|
|
53
|
+
// Ubuntu LTS = even major, .04 minor (20.04, 22.04, 24.04, 26.04, ...).
|
|
54
|
+
const isLts = minor === 4 && major % 2 === 0;
|
|
55
|
+
return { appliance, major, minor, isLts };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function compareDescending(a: ParsedUbuntu, b: ParsedUbuntu): number {
|
|
59
|
+
if (a.major !== b.major) return b.major - a.major;
|
|
60
|
+
return b.minor - a.minor;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the option list used by the select prompt and tests.
|
|
65
|
+
* Exported so tests can assert on filtering/sorting without mocking @clack.
|
|
66
|
+
*/
|
|
67
|
+
export function buildUbuntuOptions(appliances: ProxmoxAppliance[]): ParsedUbuntu[] {
|
|
68
|
+
return appliances
|
|
69
|
+
.map(parseUbuntuPackage)
|
|
70
|
+
.filter((entry): entry is ParsedUbuntu => entry !== null)
|
|
71
|
+
.filter((entry) => (entry.appliance.section ?? 'system') === 'system')
|
|
72
|
+
.sort(compareDescending);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Pre-select rule:
|
|
77
|
+
* 1. Exact match on currentTemplate (reconfigure with current revision still on mirror).
|
|
78
|
+
* 2. Family match on currentTemplate package (e.g. user has 24.04 -1 cached, mirror has -2).
|
|
79
|
+
* 3. Newest LTS in the catalog.
|
|
80
|
+
* 4. Newest entry overall.
|
|
81
|
+
*/
|
|
82
|
+
export function pickInitialTemplate(
|
|
83
|
+
options: ParsedUbuntu[],
|
|
84
|
+
currentTemplate?: string,
|
|
85
|
+
): string | undefined {
|
|
86
|
+
if (options.length === 0) return undefined;
|
|
87
|
+
|
|
88
|
+
if (currentTemplate) {
|
|
89
|
+
const exact = options.find((e) => e.appliance.template === currentTemplate);
|
|
90
|
+
if (exact) return exact.appliance.template;
|
|
91
|
+
|
|
92
|
+
const familyName = currentTemplate.match(/^(ubuntu-\d+\.\d+-standard)/)?.[1];
|
|
93
|
+
if (familyName) {
|
|
94
|
+
const family = options.find((e) => e.appliance.package === familyName);
|
|
95
|
+
if (family) return family.appliance.template;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const newestLts = options.find((e) => e.isLts);
|
|
100
|
+
return (newestLts ?? options[0]).appliance.template;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function selectUbuntuApplianceFromCatalog(
|
|
104
|
+
credentials: ProxmoxCredentials,
|
|
105
|
+
nodeName: string,
|
|
106
|
+
opts: { currentTemplate?: string } = {},
|
|
107
|
+
): Promise<TemplateSelectionResult> {
|
|
108
|
+
console.log('\nFetching Proxmox template catalog...');
|
|
109
|
+
const result = await listAvailableAppliances(credentials, nodeName);
|
|
110
|
+
|
|
111
|
+
if (!result.success) {
|
|
112
|
+
console.log(`⚠ Could not fetch template catalog from Proxmox: ${result.message}`);
|
|
113
|
+
console.log(' You can still configure the service by entering a template filename manually.');
|
|
114
|
+
console.log(
|
|
115
|
+
' After saving, run `pveam download <storage> <template>` on the Proxmox host before deploying.',
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const manual = await promptText({
|
|
119
|
+
message: 'Template filename:',
|
|
120
|
+
placeholder: 'ubuntu-24.04-standard_24.04-2_amd64.tar.zst',
|
|
121
|
+
validate: validateRequired('Template filename'),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (typeof manual !== 'string' || manual.trim() === '') {
|
|
125
|
+
return { kind: 'cancelled' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { kind: 'selected', choice: { template: manual.trim(), source: 'manual' } };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const options = buildUbuntuOptions(result.data);
|
|
132
|
+
|
|
133
|
+
if (options.length === 0) {
|
|
134
|
+
return {
|
|
135
|
+
kind: 'error',
|
|
136
|
+
message:
|
|
137
|
+
'Proxmox aplinfo returned no Ubuntu standard templates. Run `pveam update` on the Proxmox host and re-try.',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const initialValue = pickInitialTemplate(options, opts.currentTemplate);
|
|
142
|
+
|
|
143
|
+
const selection = await p.select<string>({
|
|
144
|
+
message: 'Default Ubuntu template:',
|
|
145
|
+
options: options.map((e) => ({
|
|
146
|
+
value: e.appliance.template,
|
|
147
|
+
label: `Ubuntu ${e.major}.${String(e.minor).padStart(2, '0')}${e.isLts ? ' LTS' : ''}`,
|
|
148
|
+
hint: e.isLts
|
|
149
|
+
? `revision ${e.appliance.version}`
|
|
150
|
+
: `revision ${e.appliance.version} · non-LTS`,
|
|
151
|
+
})),
|
|
152
|
+
initialValue,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (p.isCancel(selection)) {
|
|
156
|
+
return { kind: 'cancelled' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { kind: 'selected', choice: { template: selection, source: 'catalog' } };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface ApplianceDownloadOutcome {
|
|
163
|
+
/** True iff the template ended up available in the chosen storage. */
|
|
164
|
+
ready: boolean;
|
|
165
|
+
/**
|
|
166
|
+
* One of:
|
|
167
|
+
* 'started-failed' — POST to aplinfo failed (Proxmox rejected the request).
|
|
168
|
+
* 'task-failed' — Download started but pveam exited non-OK (typically exit 8 = HTTP error).
|
|
169
|
+
* 'timeout' — Polling exhausted the budget without seeing 'stopped'.
|
|
170
|
+
* 'ok' — pveam exited 0 and template should now exist in storage.
|
|
171
|
+
*/
|
|
172
|
+
reason: 'started-failed' | 'task-failed' | 'timeout' | 'ok';
|
|
173
|
+
/** The pveam exit status when reason='task-failed', or null otherwise. */
|
|
174
|
+
exitStatus?: string;
|
|
175
|
+
/** The error message from Proxmox when reason='started-failed'. */
|
|
176
|
+
startError?: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface RunApplianceDownloadInput {
|
|
180
|
+
credentials: ProxmoxCredentials;
|
|
181
|
+
targetNode: string;
|
|
182
|
+
templateStorage: string;
|
|
183
|
+
templateFilename: string;
|
|
184
|
+
/** Polling budget in 5s ticks; defaults to 60 (= 5 minutes). */
|
|
185
|
+
maxAttempts?: number;
|
|
186
|
+
/** Override the default 5000ms tick — primarily to keep tests fast. */
|
|
187
|
+
pollIntervalMs?: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Trigger a `pveam download` on the Proxmox host and poll until it finishes.
|
|
192
|
+
* Drives a FuelGauge for user-visible progress. Distinguishes real failures
|
|
193
|
+
* (Proxmox rejected the start, or pveam exited non-OK) from timeout, so the
|
|
194
|
+
* caller can render an accurate next-steps message instead of always blaming
|
|
195
|
+
* "5-minute timeout" for any non-success path (the bug that motivated this
|
|
196
|
+
* refactor).
|
|
197
|
+
*/
|
|
198
|
+
export async function runApplianceDownload(
|
|
199
|
+
input: RunApplianceDownloadInput,
|
|
200
|
+
): Promise<ApplianceDownloadOutcome> {
|
|
201
|
+
const {
|
|
202
|
+
credentials,
|
|
203
|
+
targetNode,
|
|
204
|
+
templateStorage,
|
|
205
|
+
templateFilename,
|
|
206
|
+
maxAttempts = 60,
|
|
207
|
+
pollIntervalMs = 5000,
|
|
208
|
+
} = input;
|
|
209
|
+
|
|
210
|
+
const startResult = await downloadAppliance(
|
|
211
|
+
credentials,
|
|
212
|
+
targetNode,
|
|
213
|
+
templateStorage,
|
|
214
|
+
templateFilename,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
if (!startResult.success) {
|
|
218
|
+
console.log(`\n✗ Failed to start download: ${startResult.message}`);
|
|
219
|
+
return { ready: false, reason: 'started-failed', startError: startResult.message };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const upid = startResult.data;
|
|
223
|
+
const gauge = new FuelGauge(`Downloading ${templateFilename}`);
|
|
224
|
+
gauge.start();
|
|
225
|
+
|
|
226
|
+
let attempts = 0;
|
|
227
|
+
while (attempts < maxAttempts) {
|
|
228
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
229
|
+
attempts++;
|
|
230
|
+
|
|
231
|
+
const statusResult = await checkTaskStatus(credentials, targetNode, upid);
|
|
232
|
+
if (!statusResult.success) {
|
|
233
|
+
gauge.addOutput(`Waiting for status... (${attempts * 5}s elapsed)`);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (statusResult.data.status !== 'stopped') {
|
|
238
|
+
gauge.addOutput(`Status: ${statusResult.data.status} (${attempts * 5}s elapsed)`);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const exit = statusResult.data.exitstatus;
|
|
243
|
+
if (exit === 'OK') {
|
|
244
|
+
gauge.stop(true);
|
|
245
|
+
return { ready: true, reason: 'ok' };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
gauge.stop(false);
|
|
249
|
+
console.log(`\n✗ pveam download failed: ${exit ?? 'unknown exit status'}`);
|
|
250
|
+
return { ready: false, reason: 'task-failed', exitStatus: exit ?? undefined };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
gauge.stop(false);
|
|
254
|
+
console.log(
|
|
255
|
+
`\n✗ Template download did not complete within ${(maxAttempts * pollIntervalMs) / 1000}s`,
|
|
256
|
+
);
|
|
257
|
+
return { ready: false, reason: 'timeout' };
|
|
258
|
+
}
|