@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.
@@ -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 { fetchAndUpgrade } from './module-upgrade';
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 (`fetchAndUpgrade` from module-upgrade.ts).
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 fetchAndUpgrade(registry, moduleId, latest.vers, db, {});
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
@@ -141,6 +141,7 @@ export async function getCompletions(words: string[], current: number): Promise<
141
141
  'remove',
142
142
  'search',
143
143
  'update',
144
+ 'upgrade',
144
145
  'verify',
145
146
  'audit',
146
147
  'backup',
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
- git_forge: {
67
- script: 'scripts/git-forge-functions.ts',
68
- legacyFactoryName: 'createForgejoGitForge',
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',
@@ -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
- data: z.record(z.unknown()),
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
+ });