@celilo/cli 0.5.0 → 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/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/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
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
import type { SystemUpdateResult } from '../../services/update/types';
|
|
42
42
|
import { getFlag, hasFlag } from '../parser';
|
|
43
43
|
import type { CommandResult } from '../types';
|
|
44
|
-
import {
|
|
44
|
+
import { fetchAndUpdate } from './module-update';
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
47
|
* Packages we manage via the global bun install. The self-update step
|
|
@@ -275,7 +275,7 @@ async function buildSnapshots(
|
|
|
275
275
|
* - `backup` calls `createModuleBackup`. Modules without an
|
|
276
276
|
* `on_backup` hook return ok (nothing to back up; not an error).
|
|
277
277
|
* - `upgrade` fetches the latest version from the registry and runs
|
|
278
|
-
* the in-place upgrade (`
|
|
278
|
+
* the in-place upgrade (`fetchAndUpdate` from module-upgrade.ts).
|
|
279
279
|
* Same code path that `module update` uses for its sweep mode.
|
|
280
280
|
* - `deploy` calls `deployModule`.
|
|
281
281
|
* - `health` calls `runModuleHealthCheck`.
|
|
@@ -314,7 +314,7 @@ function buildOps(registry: RegistryClient, wasDeployed: Set<string>): Orchestra
|
|
|
314
314
|
if (!latest) {
|
|
315
315
|
return { ok: false, error: `${moduleId}: no non-yanked version` };
|
|
316
316
|
}
|
|
317
|
-
const result = await
|
|
317
|
+
const result = await fetchAndUpdate(registry, moduleId, latest.vers, db, {});
|
|
318
318
|
if (result.status === 'success') return { ok: true };
|
|
319
319
|
if (result.status === 'failed') return { ok: false, error: result.error };
|
|
320
320
|
// 'skipped' isn't expected here (the module IS installed), but
|
package/src/cli/completion.ts
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -63,6 +63,7 @@ import { handleModuleShowConfig, handleModuleShowZone } from './commands/module-
|
|
|
63
63
|
import { handleModuleStatus } from './commands/module-status';
|
|
64
64
|
import { handleModuleTerraformUnlock } from './commands/module-terraform-unlock';
|
|
65
65
|
import { handleModuleTypesCheck, handleModuleTypesGenerate } from './commands/module-types';
|
|
66
|
+
import { handleModuleUpdate } from './commands/module-update';
|
|
66
67
|
import { handleModuleUpgrade } from './commands/module-upgrade';
|
|
67
68
|
import { moduleVerify } from './commands/module-verify';
|
|
68
69
|
import { handlePackage } from './commands/package';
|
|
@@ -1266,6 +1267,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1266
1267
|
case 'remove':
|
|
1267
1268
|
return handleModuleRemove(parsed.args, parsed.flags);
|
|
1268
1269
|
case 'update':
|
|
1270
|
+
return handleModuleUpdate(parsed.args, parsed.flags);
|
|
1271
|
+
case 'upgrade':
|
|
1269
1272
|
return handleModuleUpgrade(parsed.args, parsed.flags);
|
|
1270
1273
|
case 'audit':
|
|
1271
1274
|
return moduleAudit(parsed.args);
|
|
@@ -63,9 +63,13 @@ const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFactoryName:
|
|
|
63
63
|
script: 'scripts/idp-functions.ts',
|
|
64
64
|
legacyFactoryName: 'createIdp',
|
|
65
65
|
},
|
|
66
|
-
|
|
67
|
-
script: 'scripts/
|
|
68
|
-
legacyFactoryName: '
|
|
66
|
+
source_forge: {
|
|
67
|
+
script: 'scripts/source-forge-functions.ts',
|
|
68
|
+
legacyFactoryName: 'createForgejoSourceForge',
|
|
69
|
+
},
|
|
70
|
+
registry_publish: {
|
|
71
|
+
script: 'scripts/registry-publish-functions.ts',
|
|
72
|
+
legacyFactoryName: 'default',
|
|
69
73
|
},
|
|
70
74
|
dhcp_server: {
|
|
71
75
|
script: 'scripts/dhcp-server-functions.ts',
|
package/src/manifest/schema.ts
CHANGED
|
@@ -237,7 +237,9 @@ export type ComputedField = z.infer<typeof ComputedFieldSchema>;
|
|
|
237
237
|
export const CapabilityProviderSchema = z.object({
|
|
238
238
|
name: z.string().min(1),
|
|
239
239
|
version: z.string().min(1),
|
|
240
|
-
|
|
240
|
+
// A purely imperative capability (functions-only, no cross-module data —
|
|
241
|
+
// e.g. registry_publish) carries no `data` block; default to {}.
|
|
242
|
+
data: z.record(z.unknown()).default({}),
|
|
241
243
|
/**
|
|
242
244
|
* Computed fields, merged into the capability's data namespace at access
|
|
243
245
|
* time. A consumer reads `$capability:<name>.<computed-field>` exactly like
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { classifyVersionDelta, parseVersion, resolveDeployPosture } from './deploy-posture';
|
|
3
|
+
|
|
4
|
+
describe('parseVersion', () => {
|
|
5
|
+
test('parses major.minor.patch+build', () => {
|
|
6
|
+
expect(parseVersion('1.2.3+28')).toEqual({
|
|
7
|
+
major: 1,
|
|
8
|
+
minor: 2,
|
|
9
|
+
patch: 3,
|
|
10
|
+
prerelease: '',
|
|
11
|
+
build: '28',
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('parses a prerelease', () => {
|
|
16
|
+
expect(parseVersion('0.5.0-alpha.9')).toEqual({
|
|
17
|
+
major: 0,
|
|
18
|
+
minor: 5,
|
|
19
|
+
patch: 0,
|
|
20
|
+
prerelease: 'alpha.9',
|
|
21
|
+
build: '',
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('tolerates a bare version', () => {
|
|
26
|
+
expect(parseVersion('2.0.0')).toEqual({
|
|
27
|
+
major: 2,
|
|
28
|
+
minor: 0,
|
|
29
|
+
patch: 0,
|
|
30
|
+
prerelease: '',
|
|
31
|
+
build: '',
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('classifyVersionDelta (highest-order component that changed)', () => {
|
|
37
|
+
test('major / minor / patch', () => {
|
|
38
|
+
expect(classifyVersionDelta('1.0.0', '2.0.0')).toBe('major');
|
|
39
|
+
expect(classifyVersionDelta('1.0.0', '1.1.0')).toBe('minor');
|
|
40
|
+
expect(classifyVersionDelta('1.0.0', '1.0.1')).toBe('patch');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('build-metadata-only change is a revision', () => {
|
|
44
|
+
expect(classifyVersionDelta('1.0.1+27', '1.0.1+28')).toBe('revision');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('prerelease-only change counts as patch (still fast)', () => {
|
|
48
|
+
expect(classifyVersionDelta('0.5.0-alpha.8', '0.5.0-alpha.9')).toBe('patch');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('identical versions → none', () => {
|
|
52
|
+
expect(classifyVersionDelta('1.0.1+28', '1.0.1+28')).toBe('none');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('highest-order wins when several components differ', () => {
|
|
56
|
+
expect(classifyVersionDelta('1.0.1+5', '1.1.0+1')).toBe('minor');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('resolveDeployPosture (precedence)', () => {
|
|
61
|
+
test('default by-semver: revision + patch ⇒ fast', () => {
|
|
62
|
+
expect(resolveDeployPosture({ installed: '1.0.1+1', next: '1.0.1+2' }).posture).toBe('fast');
|
|
63
|
+
expect(resolveDeployPosture({ installed: '1.0.1', next: '1.0.2' }).posture).toBe('fast');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('default by-semver: minor + major ⇒ safe', () => {
|
|
67
|
+
expect(resolveDeployPosture({ installed: '1.0.0', next: '1.1.0' }).posture).toBe('safe');
|
|
68
|
+
expect(resolveDeployPosture({ installed: '1.0.0', next: '2.0.0' }).posture).toBe('safe');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('per-release deploy_posture overrides the semver default', () => {
|
|
72
|
+
// a minor bump would default to safe; the release pins fast.
|
|
73
|
+
const r = resolveDeployPosture({ installed: '1.0.0', next: '1.1.0', releasePosture: 'fast' });
|
|
74
|
+
expect(r.posture).toBe('fast');
|
|
75
|
+
expect(r.reason).toContain('release deploy_posture');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('module always-safe is a hard floor — beats a per-release fast', () => {
|
|
79
|
+
const r = resolveDeployPosture({
|
|
80
|
+
installed: '1.0.1',
|
|
81
|
+
next: '1.0.2',
|
|
82
|
+
releasePosture: 'fast',
|
|
83
|
+
modulePolicy: 'always-safe',
|
|
84
|
+
});
|
|
85
|
+
expect(r.posture).toBe('safe');
|
|
86
|
+
expect(r.reason).toContain('always-safe');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('module always-fast forces fast on a minor bump (when no release override)', () => {
|
|
90
|
+
expect(
|
|
91
|
+
resolveDeployPosture({ installed: '1.0.0', next: '1.1.0', modulePolicy: 'always-fast' })
|
|
92
|
+
.posture,
|
|
93
|
+
).toBe('fast');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('a per-release safe still beats always-fast (release intent wins below the floor)', () => {
|
|
97
|
+
expect(
|
|
98
|
+
resolveDeployPosture({
|
|
99
|
+
installed: '1.0.1',
|
|
100
|
+
next: '1.0.2',
|
|
101
|
+
releasePosture: 'safe',
|
|
102
|
+
modulePolicy: 'always-fast',
|
|
103
|
+
}).posture,
|
|
104
|
+
).toBe('safe');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy posture — fast vs. safe — for `celilo module upgrade` (ISS-0138,
|
|
3
|
+
* v2/BUILD_BUS.md).
|
|
4
|
+
*
|
|
5
|
+
* CD is CI-driven, so no operator is present to pass `--no-backup`. The posture
|
|
6
|
+
* (fast = skip backup + extended verify; safe = backup + full verify) is
|
|
7
|
+
* DERIVED, with this precedence:
|
|
8
|
+
* 1. per-module `upgrade_policy: always-safe` — a hard floor (always safe).
|
|
9
|
+
* 2. per-release `deploy_posture` stamped in the .netapp release metadata.
|
|
10
|
+
* 3. per-module `upgrade_policy: always-fast`.
|
|
11
|
+
* 4. default: the semver delta of installed → next (revision/patch = fast;
|
|
12
|
+
* minor/major = safe).
|
|
13
|
+
*
|
|
14
|
+
* celilo reads version NUMBERS, never changesets — the default needs only the
|
|
15
|
+
* versions. All functions here are pure (Rule 10).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type DeployPosture = 'fast' | 'safe';
|
|
19
|
+
export type VersionDelta = 'none' | 'revision' | 'patch' | 'minor' | 'major';
|
|
20
|
+
export type UpgradePolicy = 'by-semver' | 'always-safe' | 'always-fast';
|
|
21
|
+
|
|
22
|
+
export interface ParsedVersion {
|
|
23
|
+
major: number;
|
|
24
|
+
minor: number;
|
|
25
|
+
patch: number;
|
|
26
|
+
/** prerelease tag, e.g. "alpha.2"; '' when absent. */
|
|
27
|
+
prerelease: string;
|
|
28
|
+
/** build metadata — celilo's `+N` registry revision; '' when absent. */
|
|
29
|
+
build: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Parse a celilo module version: `major.minor.patch[-prerelease][+build]`. */
|
|
33
|
+
export function parseVersion(version: string): ParsedVersion {
|
|
34
|
+
const [coreAndPre = '', build = ''] = version.split('+');
|
|
35
|
+
const [core = '', prerelease = ''] = coreAndPre.split('-');
|
|
36
|
+
const parts = core.split('.');
|
|
37
|
+
const num = (i: number) => Number.parseInt(parts[i] ?? '', 10) || 0;
|
|
38
|
+
return { major: num(0), minor: num(1), patch: num(2), prerelease, build };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Classify the delta between an installed version and the next one by the
|
|
43
|
+
* highest-order component that changed. Assumes `next` >= `installed` (the poll
|
|
44
|
+
* only upgrades). A prerelease-only change counts as `patch` (still fast); a
|
|
45
|
+
* build-metadata-only change (`+N`) is `revision`.
|
|
46
|
+
*/
|
|
47
|
+
export function classifyVersionDelta(installed: string, next: string): VersionDelta {
|
|
48
|
+
const a = parseVersion(installed);
|
|
49
|
+
const b = parseVersion(next);
|
|
50
|
+
if (a.major !== b.major) return 'major';
|
|
51
|
+
if (a.minor !== b.minor) return 'minor';
|
|
52
|
+
if (a.patch !== b.patch) return 'patch';
|
|
53
|
+
if (a.prerelease !== b.prerelease) return 'patch';
|
|
54
|
+
if (a.build !== b.build) return 'revision';
|
|
55
|
+
return 'none';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the deploy posture for an upgrade. Precedence: `always-safe` floor →
|
|
60
|
+
* per-release override → `always-fast` → semver-delta default. Returns the
|
|
61
|
+
* posture plus a short reason for operator-visible logging.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveDeployPosture(opts: {
|
|
64
|
+
installed: string;
|
|
65
|
+
next: string;
|
|
66
|
+
releasePosture?: DeployPosture | null;
|
|
67
|
+
modulePolicy?: UpgradePolicy;
|
|
68
|
+
}): { posture: DeployPosture; reason: string } {
|
|
69
|
+
const policy = opts.modulePolicy ?? 'by-semver';
|
|
70
|
+
|
|
71
|
+
if (policy === 'always-safe') {
|
|
72
|
+
return { posture: 'safe', reason: 'module upgrade_policy=always-safe' };
|
|
73
|
+
}
|
|
74
|
+
if (opts.releasePosture) {
|
|
75
|
+
return {
|
|
76
|
+
posture: opts.releasePosture,
|
|
77
|
+
reason: `release deploy_posture=${opts.releasePosture}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (policy === 'always-fast') {
|
|
81
|
+
return { posture: 'fast', reason: 'module upgrade_policy=always-fast' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const delta = classifyVersionDelta(opts.installed, opts.next);
|
|
85
|
+
const posture: DeployPosture = delta === 'minor' || delta === 'major' ? 'safe' : 'fast';
|
|
86
|
+
return { posture, reason: `semver delta=${delta}` };
|
|
87
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
7
|
+
import { parse as parseYaml } from 'yaml';
|
|
6
8
|
import { closeDb, getDb } from '../db/client';
|
|
9
|
+
import { ModuleManifestSchema } from '../manifest/schema';
|
|
7
10
|
import { ModuleSubscriptionSchema } from '../manifest/schema';
|
|
8
11
|
import type { ModuleManifest } from '../manifest/schema';
|
|
9
12
|
import { setupTestDatabase as migrateDbFile } from '../test-utils/setup-test-db';
|
|
@@ -341,3 +344,30 @@ describe('resyncAllSubscriptions (ISS-0088)', () => {
|
|
|
341
344
|
expect(subscriberNames()).toEqual(['caddy.reconcile']);
|
|
342
345
|
});
|
|
343
346
|
});
|
|
347
|
+
|
|
348
|
+
describe('build-bus registry-poll wiring (ISS-0139)', () => {
|
|
349
|
+
// The CD poll is wired as a celilo-mgmt manifest subscription so it registers
|
|
350
|
+
// ONLY on the management host. Guard the exact handler/pattern against drift.
|
|
351
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
|
|
352
|
+
|
|
353
|
+
it('celilo-mgmt subscribes the registry poll to timer.tick.15m', () => {
|
|
354
|
+
const manifestPath = join(REPO_ROOT, 'modules', 'celilo-mgmt', 'manifest.yml');
|
|
355
|
+
const manifest = ModuleManifestSchema.parse(parseYaml(readFileSync(manifestPath, 'utf-8')));
|
|
356
|
+
|
|
357
|
+
const poll = manifest.subscriptions?.find((s) => s.name === 'registry-poll');
|
|
358
|
+
expect(poll).toBeDefined();
|
|
359
|
+
expect(poll?.pattern).toBe('timer.tick.15m');
|
|
360
|
+
// A literal handler command (the CLI poll), not a hook — see manifest comment.
|
|
361
|
+
expect(poll?.handler).toBe('celilo module upgrade');
|
|
362
|
+
expect(poll?.hook).toBeUndefined();
|
|
363
|
+
|
|
364
|
+
const resolved = resolveSubscription(
|
|
365
|
+
// biome-ignore lint/style/noNonNullAssertion: asserted defined above
|
|
366
|
+
poll!,
|
|
367
|
+
'celilo-mgmt',
|
|
368
|
+
'/modules/celilo-mgmt',
|
|
369
|
+
);
|
|
370
|
+
expect(resolved.name).toBe('celilo-mgmt.registry-poll');
|
|
371
|
+
expect(resolved.handler).toBe('celilo module upgrade');
|
|
372
|
+
});
|
|
373
|
+
});
|