@celilo/cli 0.7.1 → 0.8.1
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/drizzle/meta/_journal.json +0 -7
- package/package.json +1 -1
- package/src/api-clients/proxmox.ts +0 -17
- package/src/cli/command-registry.ts +1 -65
- package/src/cli/commands/module-changeset.test.ts +52 -0
- package/src/cli/commands/module-changeset.ts +65 -0
- package/src/cli/commands/module-publish.ts +10 -1
- package/src/cli/commands/module-version.test.ts +118 -0
- package/src/cli/commands/module-version.ts +155 -0
- package/src/cli/commands/proxmox-node-list.ts +34 -1
- package/src/cli/completion.ts +3 -11
- package/src/cli/index.ts +6 -15
- package/src/db/schema.ts +0 -11
- package/src/manifest/schema.ts +24 -0
- package/src/module/versioning/changeset-version.test.ts +108 -0
- package/src/module/versioning/changeset-version.ts +139 -0
- package/src/services/deployed-systems.test.ts +1 -73
- package/src/services/deployed-systems.ts +0 -72
- package/src/services/module-validator/git-hygiene.test.ts +35 -0
- package/src/services/module-validator/git-hygiene.ts +36 -17
- package/src/services/module-validator/index.ts +2 -1
- package/src/variables/context.ts +7 -36
- package/drizzle/0012_module_systems_sizing.sql +0 -3
- package/src/cli/commands/proxmox-instance-list.test.ts +0 -77
- package/src/cli/commands/proxmox-instance-list.ts +0 -137
- package/src/cli/commands/proxmox-instance-resize.ts +0 -233
- package/src/cli/commands/proxmox-resize-guards.test.ts +0 -55
- package/src/cli/commands/proxmox-resize-guards.ts +0 -102
- package/src/cli/commands/proxmox-service.ts +0 -38
package/src/db/schema.ts
CHANGED
|
@@ -392,17 +392,6 @@ export const moduleSystems = sqliteTable(
|
|
|
392
392
|
serviceId: text('service_id').references(() => containerServices.id),
|
|
393
393
|
/** Proxmox VMID — set only for proxmox containers. */
|
|
394
394
|
vmid: integer('vmid'),
|
|
395
|
-
// Canonical deployed SIZE of this system (ISS-0150). For celilo-provisioned
|
|
396
|
-
// VM/LXC instances only (null for machine-pool systems celilo doesn't size).
|
|
397
|
-
// Seeded from the module's `requires.system` at first provision, then owned
|
|
398
|
-
// by `celilo proxmox … resize` — `requires.system` is only the minimum floor,
|
|
399
|
-
// never the live size. See CLAUDE.md "requires.system is the MINIMUM".
|
|
400
|
-
/** vCPU cores. */
|
|
401
|
-
cpu: integer('cpu'),
|
|
402
|
-
/** RAM in MB. */
|
|
403
|
-
memory: integer('memory'),
|
|
404
|
-
/** Root disk in GB. */
|
|
405
|
-
disk: integer('disk'),
|
|
406
395
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
407
396
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
408
397
|
},
|
package/src/manifest/schema.ts
CHANGED
|
@@ -443,6 +443,30 @@ export const ModuleManifestSchema = z
|
|
|
443
443
|
version: z.string().regex(/^\d+\.\d+\.\d+$/, 'Version must be semantic version (e.g., 1.0.0)'),
|
|
444
444
|
description: z.string().optional(),
|
|
445
445
|
|
|
446
|
+
/**
|
|
447
|
+
* How `manifest.yml#version` (the PAYLOAD version) is determined — see
|
|
448
|
+
* v2/MODULE_VERSIONING.md / ISS-0151. The capability *contract* version
|
|
449
|
+
* lives in `provides.capabilities[].version` and is unrelated to this.
|
|
450
|
+
*
|
|
451
|
+
* - `changeset` — first-party apps/content (lunacycle): the version is
|
|
452
|
+
* authored via `.changeset/*.md` keyed by the module id; `celilo module
|
|
453
|
+
* version` stamps it here. Ordered by the `+N` revision; semver is a
|
|
454
|
+
* blast-radius/posture signal, not a contract.
|
|
455
|
+
* - `pin` — wrapper modules (caddy, forgejo): `resolver` returns the
|
|
456
|
+
* upstream version actually installed; `module check` fails on drift.
|
|
457
|
+
* - `recipe` — payload-less modules (iptables): no software version; `+N`
|
|
458
|
+
* orders. The default when this block is absent, preserving today's
|
|
459
|
+
* behaviour for unmigrated modules.
|
|
460
|
+
*/
|
|
461
|
+
version_source: z
|
|
462
|
+
.object({
|
|
463
|
+
kind: z.enum(['pin', 'changeset', 'recipe']),
|
|
464
|
+
/** For `kind: pin`: path (module-relative) to a script printing the upstream version. */
|
|
465
|
+
resolver: z.string().optional(),
|
|
466
|
+
})
|
|
467
|
+
.strict()
|
|
468
|
+
.optional(),
|
|
469
|
+
|
|
446
470
|
requires: z
|
|
447
471
|
.object({
|
|
448
472
|
capabilities: z.array(CapabilityRequirementSchema).default([]),
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
type BumpType,
|
|
4
|
+
type ModuleVersionPlan,
|
|
5
|
+
applyBump,
|
|
6
|
+
maxBump,
|
|
7
|
+
parseChangeset,
|
|
8
|
+
planModuleVersion,
|
|
9
|
+
renderChangelogSection,
|
|
10
|
+
} from './changeset-version';
|
|
11
|
+
|
|
12
|
+
describe('parseChangeset', () => {
|
|
13
|
+
it('parses quoted id + bump and the summary body', () => {
|
|
14
|
+
const r = parseChangeset('---\n"lunacycle": minor\n---\n\nAdded a thing.\n');
|
|
15
|
+
expect(r.bumps).toEqual({ lunacycle: 'minor' });
|
|
16
|
+
expect(r.summary).toBe('Added a thing.');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('parses unquoted ids and multiple modules', () => {
|
|
20
|
+
const r = parseChangeset('---\nlunacycle: patch\nnigredo: major\n---\nfix\n');
|
|
21
|
+
expect(r.bumps).toEqual({ lunacycle: 'patch', nigredo: 'major' });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('throws on a missing frontmatter fence', () => {
|
|
25
|
+
expect(() => parseChangeset('just a summary, no frontmatter')).toThrow(/frontmatter/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('throws on an invalid bump keyword', () => {
|
|
29
|
+
expect(() => parseChangeset('---\nlunacycle: huge\n---\n')).toThrow(/major\|minor\|patch/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('throws on a frontmatter line without a colon', () => {
|
|
33
|
+
expect(() => parseChangeset('---\nlunacycle minor\n---\n')).toThrow(/not "id: bump"/);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('maxBump', () => {
|
|
38
|
+
it('ranks major > minor > patch', () => {
|
|
39
|
+
expect(maxBump(['patch', 'major', 'minor'])).toBe('major');
|
|
40
|
+
expect(maxBump(['patch', 'minor'])).toBe('minor');
|
|
41
|
+
expect(maxBump(['patch'])).toBe('patch');
|
|
42
|
+
});
|
|
43
|
+
it('returns null for an empty list', () => {
|
|
44
|
+
expect(maxBump([])).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('applyBump', () => {
|
|
49
|
+
it('bumps each level and zeroes lower segments', () => {
|
|
50
|
+
expect(applyBump('1.2.3', 'major')).toBe('2.0.0');
|
|
51
|
+
expect(applyBump('1.2.3', 'minor')).toBe('1.3.0');
|
|
52
|
+
expect(applyBump('1.2.3', 'patch')).toBe('1.2.4');
|
|
53
|
+
});
|
|
54
|
+
it('throws on a non x.y.z version', () => {
|
|
55
|
+
expect(() => applyBump('1.2', 'patch')).toThrow(/x\.y\.z/);
|
|
56
|
+
expect(() => applyBump('1.0.0+4', 'patch')).toThrow(/x\.y\.z/);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('planModuleVersion', () => {
|
|
61
|
+
const cs = (id: string, bump: BumpType, summary = '') =>
|
|
62
|
+
parseChangeset(`---\n"${id}": ${bump}\n---\n${summary}\n`);
|
|
63
|
+
|
|
64
|
+
it('returns null when no changeset targets the module', () => {
|
|
65
|
+
expect(planModuleVersion('1.0.0', 'lunacycle', [])).toBeNull();
|
|
66
|
+
expect(planModuleVersion('1.0.0', 'lunacycle', [cs('nigredo', 'minor')])).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('takes the max bump across relevant changesets', () => {
|
|
70
|
+
const plan = planModuleVersion('1.2.3', 'lunacycle', [
|
|
71
|
+
cs('lunacycle', 'patch', 'a'),
|
|
72
|
+
cs('lunacycle', 'minor', 'b'),
|
|
73
|
+
cs('nigredo', 'major', 'unrelated'),
|
|
74
|
+
]);
|
|
75
|
+
expect(plan).not.toBeNull();
|
|
76
|
+
expect(plan?.bump).toBe('minor');
|
|
77
|
+
expect(plan?.next).toBe('1.3.0');
|
|
78
|
+
expect(plan?.entries).toEqual(['a', 'b']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('omits empty summaries from entries', () => {
|
|
82
|
+
const plan = planModuleVersion('1.0.0', 'lunacycle', [
|
|
83
|
+
cs('lunacycle', 'patch', ''),
|
|
84
|
+
cs('lunacycle', 'patch', 'real'),
|
|
85
|
+
]);
|
|
86
|
+
expect(plan?.entries).toEqual(['real']);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('renderChangelogSection', () => {
|
|
91
|
+
it('renders a newest-on-top section with the bump heading', () => {
|
|
92
|
+
const plan = planModuleVersion('1.0.0', 'lunacycle', [
|
|
93
|
+
parseChangeset('---\nlunacycle: minor\n---\nAdded tasks view.\n'),
|
|
94
|
+
]);
|
|
95
|
+
expect(plan).not.toBeNull();
|
|
96
|
+
const md = renderChangelogSection(plan as ModuleVersionPlan);
|
|
97
|
+
expect(md).toContain('## 1.1.0');
|
|
98
|
+
expect(md).toContain('### Minor Changes');
|
|
99
|
+
expect(md).toContain('- Added tasks view.');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('falls back to a generic bullet when there are no summaries', () => {
|
|
103
|
+
const plan = planModuleVersion('1.0.0', 'lunacycle', [
|
|
104
|
+
parseChangeset('---\nlunacycle: patch\n---\n'),
|
|
105
|
+
]);
|
|
106
|
+
expect(renderChangelogSection(plan as ModuleVersionPlan)).toContain('- Version bump.');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Changeset-driven module versioning (ISS-0151 / v2/MODULE_VERSIONING.md).
|
|
3
|
+
*
|
|
4
|
+
* For `version_source: { kind: changeset }` modules, the payload version is
|
|
5
|
+
* authored via `.changeset/*.md` files — the same on-disk format the changesets
|
|
6
|
+
* CLI uses, but keyed by the MODULE id (Option A) rather than an npm package, so
|
|
7
|
+
* a celilo module needs no package.json. `celilo module version` reads those
|
|
8
|
+
* files, computes the next `manifest.yml#version`, and writes a CHANGELOG.
|
|
9
|
+
*
|
|
10
|
+
* This file is the pure core: parsing + semver math, no filesystem. The command
|
|
11
|
+
* (module-version.ts) does the I/O.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type BumpType = 'major' | 'minor' | 'patch';
|
|
15
|
+
|
|
16
|
+
const BUMP_RANK: Record<BumpType, number> = { patch: 1, minor: 2, major: 3 };
|
|
17
|
+
|
|
18
|
+
export function isBumpType(s: string): s is BumpType {
|
|
19
|
+
return s === 'major' || s === 'minor' || s === 'patch';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ParsedChangeset {
|
|
23
|
+
/** module id → declared bump, from the `---` frontmatter. */
|
|
24
|
+
bumps: Record<string, BumpType>;
|
|
25
|
+
/** the markdown body after the frontmatter, trimmed. */
|
|
26
|
+
summary: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse one changeset `.md`: a YAML-ish frontmatter block fenced by `---`
|
|
31
|
+
* mapping `"<module-id>": <bump>` (quotes optional), followed by a prose body.
|
|
32
|
+
*
|
|
33
|
+
* Throws on a malformed frontmatter fence or an invalid bump keyword — a
|
|
34
|
+
* changeset that can't be understood must fail loudly, not silently no-op.
|
|
35
|
+
*/
|
|
36
|
+
export function parseChangeset(content: string): ParsedChangeset {
|
|
37
|
+
const normalized = content.replace(/\r\n/g, '\n');
|
|
38
|
+
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
39
|
+
if (!match) {
|
|
40
|
+
throw new Error('changeset has no `---` frontmatter block');
|
|
41
|
+
}
|
|
42
|
+
const [, frontmatter, body] = match;
|
|
43
|
+
|
|
44
|
+
const bumps: Record<string, BumpType> = {};
|
|
45
|
+
for (const rawLine of frontmatter.split('\n')) {
|
|
46
|
+
const line = rawLine.trim();
|
|
47
|
+
if (!line) continue;
|
|
48
|
+
const sep = line.indexOf(':');
|
|
49
|
+
if (sep === -1) {
|
|
50
|
+
throw new Error(`changeset frontmatter line is not "id: bump": ${line}`);
|
|
51
|
+
}
|
|
52
|
+
const id = line
|
|
53
|
+
.slice(0, sep)
|
|
54
|
+
.trim()
|
|
55
|
+
.replace(/^['"]|['"]$/g, '');
|
|
56
|
+
const bump = line
|
|
57
|
+
.slice(sep + 1)
|
|
58
|
+
.trim()
|
|
59
|
+
.replace(/^['"]|['"]$/g, '');
|
|
60
|
+
if (!id) throw new Error(`changeset frontmatter has an empty module id: ${line}`);
|
|
61
|
+
if (!isBumpType(bump)) {
|
|
62
|
+
throw new Error(`changeset bump for "${id}" must be major|minor|patch, got "${bump}"`);
|
|
63
|
+
}
|
|
64
|
+
bumps[id] = bump;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { bumps, summary: body.trim() };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Highest-ranked bump in the list, or null if empty. */
|
|
71
|
+
export function maxBump(bumps: BumpType[]): BumpType | null {
|
|
72
|
+
let best: BumpType | null = null;
|
|
73
|
+
for (const b of bumps) {
|
|
74
|
+
if (best === null || BUMP_RANK[b] > BUMP_RANK[best]) best = b;
|
|
75
|
+
}
|
|
76
|
+
return best;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Apply a semver bump to an `x.y.z` string. Throws if `version` isn't `x.y.z`. */
|
|
80
|
+
export function applyBump(version: string, bump: BumpType): string {
|
|
81
|
+
const m = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
|
82
|
+
if (!m) throw new Error(`version must be x.y.z, got "${version}"`);
|
|
83
|
+
const [major, minor, patch] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
84
|
+
switch (bump) {
|
|
85
|
+
case 'major':
|
|
86
|
+
return `${major + 1}.0.0`;
|
|
87
|
+
case 'minor':
|
|
88
|
+
return `${major}.${minor + 1}.0`;
|
|
89
|
+
case 'patch':
|
|
90
|
+
return `${major}.${minor}.${patch + 1}`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ModuleVersionPlan {
|
|
95
|
+
current: string;
|
|
96
|
+
next: string;
|
|
97
|
+
bump: BumpType;
|
|
98
|
+
/** summaries of the changesets that target this module, for the CHANGELOG. */
|
|
99
|
+
entries: string[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Compute the next version for `moduleId` from the parsed changesets. Returns
|
|
104
|
+
* null when no changeset targets this module — "nothing to version" is a clean
|
|
105
|
+
* no-op, not an error (e.g. a docs-only PR added a changeset for another module,
|
|
106
|
+
* or none at all).
|
|
107
|
+
*/
|
|
108
|
+
export function planModuleVersion(
|
|
109
|
+
current: string,
|
|
110
|
+
moduleId: string,
|
|
111
|
+
changesets: ParsedChangeset[],
|
|
112
|
+
): ModuleVersionPlan | null {
|
|
113
|
+
const relevant = changesets.filter((c) => moduleId in c.bumps);
|
|
114
|
+
if (relevant.length === 0) return null;
|
|
115
|
+
|
|
116
|
+
const bump = maxBump(relevant.map((c) => c.bumps[moduleId]));
|
|
117
|
+
// relevant is non-empty, so maxBump is non-null.
|
|
118
|
+
if (bump === null) return null;
|
|
119
|
+
|
|
120
|
+
const entries = relevant.map((c) => c.summary).filter((s) => s.length > 0);
|
|
121
|
+
return { current, next: applyBump(current, bump), bump, entries };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Render a CHANGELOG section for a computed plan (newest-on-top convention).
|
|
126
|
+
* The command prepends this below the file-level `# <module-id>` header.
|
|
127
|
+
*/
|
|
128
|
+
export function renderChangelogSection(plan: ModuleVersionPlan): string {
|
|
129
|
+
const heading: Record<BumpType, string> = {
|
|
130
|
+
major: 'Major Changes',
|
|
131
|
+
minor: 'Minor Changes',
|
|
132
|
+
patch: 'Patch Changes',
|
|
133
|
+
};
|
|
134
|
+
const lines = [`## ${plan.next}`, '', `### ${heading[plan.bump]}`, ''];
|
|
135
|
+
const bullets = plan.entries.length > 0 ? plan.entries : ['Version bump.'];
|
|
136
|
+
for (const e of bullets) lines.push(`- ${e.replace(/\n+/g, ' ').trim()}`);
|
|
137
|
+
lines.push('');
|
|
138
|
+
return lines.join('\n');
|
|
139
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { rm } from 'node:fs/promises';
|
|
4
|
-
import { and, eq } from 'drizzle-orm';
|
|
5
4
|
import { type DbClient, createDbClient } from '../db/client';
|
|
6
5
|
import {
|
|
7
6
|
containerServices,
|
|
@@ -11,7 +10,7 @@ import {
|
|
|
11
10
|
moduleSystems,
|
|
12
11
|
modules,
|
|
13
12
|
} from '../db/schema';
|
|
14
|
-
import { backfillModuleSystems, getModuleSystems
|
|
13
|
+
import { backfillModuleSystems, getModuleSystems } from './deployed-systems';
|
|
15
14
|
|
|
16
15
|
const TEST_DB_PATH = './test-deployed-systems.db';
|
|
17
16
|
|
|
@@ -234,74 +233,3 @@ describe('backfillModuleSystems', () => {
|
|
|
234
233
|
expect(getModuleSystems('namecheap', db)).toHaveLength(0);
|
|
235
234
|
});
|
|
236
235
|
});
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Canonical instance sizing (ISS-0150): sizing is seeded onto module_systems
|
|
240
|
-
* once at first provision and then OWNED by `celilo proxmox … resize` — a routine
|
|
241
|
-
* re-deploy must never reset a resized instance back to its manifest minimum.
|
|
242
|
-
*/
|
|
243
|
-
describe('upsertDeployedSystem sizing — seed-once (ISS-0150)', () => {
|
|
244
|
-
let db: DbClient;
|
|
245
|
-
|
|
246
|
-
beforeEach(() => {
|
|
247
|
-
db = createDbClient({ path: TEST_DB_PATH });
|
|
248
|
-
db.insert(modules)
|
|
249
|
-
.values({
|
|
250
|
-
id: 'm1',
|
|
251
|
-
name: 'm1',
|
|
252
|
-
version: '1.0.0',
|
|
253
|
-
manifestData: {},
|
|
254
|
-
sourcePath: '/tmp/m1',
|
|
255
|
-
state: 'VERIFIED',
|
|
256
|
-
})
|
|
257
|
-
.run();
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
afterEach(async () => {
|
|
261
|
-
db.$client.close();
|
|
262
|
-
for (const suffix of ['', '-shm', '-wal']) {
|
|
263
|
-
const p = `${TEST_DB_PATH}${suffix}`;
|
|
264
|
-
if (existsSync(p)) await rm(p);
|
|
265
|
-
}
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
const sizeArgs = (memory: number) => ({
|
|
269
|
-
name: 'main',
|
|
270
|
-
hostname: 'h',
|
|
271
|
-
ipv4Address: '10.0.0.5/24',
|
|
272
|
-
zone: 'app' as const,
|
|
273
|
-
infraType: 'container_service' as const,
|
|
274
|
-
vmid: 200,
|
|
275
|
-
cpu: 4,
|
|
276
|
-
memory,
|
|
277
|
-
disk: 80,
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
const row = () =>
|
|
281
|
-
db
|
|
282
|
-
.select()
|
|
283
|
-
.from(moduleSystems)
|
|
284
|
-
.where(and(eq(moduleSystems.moduleId, 'm1'), eq(moduleSystems.name, 'main')))
|
|
285
|
-
.get();
|
|
286
|
-
|
|
287
|
-
test('seeds sizing on first insert', () => {
|
|
288
|
-
upsertDeployedSystem(db, 'm1', sizeArgs(8192));
|
|
289
|
-
const r = row();
|
|
290
|
-
expect(r?.cpu).toBe(4);
|
|
291
|
-
expect(r?.memory).toBe(8192);
|
|
292
|
-
expect(r?.disk).toBe(80);
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
test('a re-deploy does NOT reset a resized instance to the manifest minimum', () => {
|
|
296
|
-
upsertDeployedSystem(db, 'm1', sizeArgs(8192)); // first provision: seed 8 GB
|
|
297
|
-
// Simulate `celilo proxmox vm resize` bumping the canonical size to 16 GB.
|
|
298
|
-
db.update(moduleSystems)
|
|
299
|
-
.set({ memory: 16384 })
|
|
300
|
-
.where(and(eq(moduleSystems.moduleId, 'm1'), eq(moduleSystems.name, 'main')))
|
|
301
|
-
.run();
|
|
302
|
-
// Re-deploy passes the manifest minimum (8 GB) again — must be ignored.
|
|
303
|
-
upsertDeployedSystem(db, 'm1', sizeArgs(8192));
|
|
304
|
-
expect(row()?.memory).toBe(16384);
|
|
305
|
-
expect(row()?.cpu).toBe(4);
|
|
306
|
-
});
|
|
307
|
-
});
|
|
@@ -52,52 +52,6 @@ export function getModuleSystems(moduleId: string, db: DbClient): DeployedSystem
|
|
|
52
52
|
return rows.map(rowToSystem).sort((a, b) => a.name.localeCompare(b.name));
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/**
|
|
56
|
-
* A celilo-provisioned instance with its canonical size (ISS-0150). CLI-internal
|
|
57
|
-
* shape (NOT the `DeployedSystem` capability type) for the `celilo proxmox
|
|
58
|
-
* vm/ct …` surface, which needs the sizing columns the capability type omits.
|
|
59
|
-
*/
|
|
60
|
-
export interface ProvisionedSystem {
|
|
61
|
-
moduleId: string;
|
|
62
|
-
name: string;
|
|
63
|
-
hostname: string;
|
|
64
|
-
ipv4Address: string;
|
|
65
|
-
zone: NetworkZone;
|
|
66
|
-
serviceId: string | null;
|
|
67
|
-
vmid: number | null;
|
|
68
|
-
/** Canonical desired size (null until seeded / for non-Proxmox). */
|
|
69
|
-
cpu: number | null;
|
|
70
|
-
memory: number | null;
|
|
71
|
-
disk: number | null;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Every celilo-provisioned (container_service) system with a Proxmox vmid, across
|
|
76
|
-
* all modules, including its canonical sizing — the read model behind
|
|
77
|
-
* `celilo proxmox vm/ct list|show`. Ordered by vmid for stable output.
|
|
78
|
-
*/
|
|
79
|
-
export function getProvisionedSystems(db: DbClient): ProvisionedSystem[] {
|
|
80
|
-
return db
|
|
81
|
-
.select()
|
|
82
|
-
.from(moduleSystems)
|
|
83
|
-
.where(eq(moduleSystems.infraType, 'container_service'))
|
|
84
|
-
.all()
|
|
85
|
-
.filter((r) => r.vmid != null)
|
|
86
|
-
.map((r) => ({
|
|
87
|
-
moduleId: r.moduleId,
|
|
88
|
-
name: r.name,
|
|
89
|
-
hostname: r.hostname,
|
|
90
|
-
ipv4Address: r.ipv4Address,
|
|
91
|
-
zone: r.zone,
|
|
92
|
-
serviceId: r.serviceId,
|
|
93
|
-
vmid: r.vmid,
|
|
94
|
-
cpu: r.cpu,
|
|
95
|
-
memory: r.memory,
|
|
96
|
-
disk: r.disk,
|
|
97
|
-
}))
|
|
98
|
-
.sort((a, b) => (a.vmid ?? 0) - (b.vmid ?? 0));
|
|
99
|
-
}
|
|
100
|
-
|
|
101
55
|
/**
|
|
102
56
|
* All container_service systems (Proxmox LXCs, droplets, …) whose zone is in
|
|
103
57
|
* `zones`, across every module — the LXC complement to machine-pool's
|
|
@@ -139,15 +93,6 @@ export interface DeployedSystemInput {
|
|
|
139
93
|
machineId?: string | null;
|
|
140
94
|
serviceId?: string | null;
|
|
141
95
|
vmid?: number | null;
|
|
142
|
-
/**
|
|
143
|
-
* Canonical deployed size (ISS-0150), seeded from the module's
|
|
144
|
-
* `requires.system` at first provision. Seed-once: written on INSERT only and
|
|
145
|
-
* preserved across re-deploys (omitted from the conflict update), so a later
|
|
146
|
-
* `celilo proxmox … resize` is not reset back to the manifest minimum.
|
|
147
|
-
*/
|
|
148
|
-
cpu?: number | null;
|
|
149
|
-
memory?: number | null;
|
|
150
|
-
disk?: number | null;
|
|
151
96
|
}
|
|
152
97
|
|
|
153
98
|
/**
|
|
@@ -172,11 +117,6 @@ export function upsertDeployedSystem(
|
|
|
172
117
|
machineId: system.machineId ?? null,
|
|
173
118
|
serviceId: system.serviceId ?? null,
|
|
174
119
|
vmid: system.vmid ?? null,
|
|
175
|
-
// Seed-once (ISS-0150): set on insert; deliberately omitted from the
|
|
176
|
-
// conflict update below so a resize survives re-deploys.
|
|
177
|
-
cpu: system.cpu ?? null,
|
|
178
|
-
memory: system.memory ?? null,
|
|
179
|
-
disk: system.disk ?? null,
|
|
180
120
|
updatedAt: new Date(),
|
|
181
121
|
})
|
|
182
122
|
.onConflictDoUpdate({
|
|
@@ -189,9 +129,6 @@ export function upsertDeployedSystem(
|
|
|
189
129
|
machineId: system.machineId ?? null,
|
|
190
130
|
serviceId: system.serviceId ?? null,
|
|
191
131
|
vmid: system.vmid ?? null,
|
|
192
|
-
// NOTE: cpu/memory/disk intentionally NOT updated here — sizing is
|
|
193
|
-
// canonical state owned by `celilo proxmox … resize`, not reset by a
|
|
194
|
-
// routine re-deploy (seed-once). See ISS-0150 / CLAUDE.md.
|
|
195
132
|
updatedAt: new Date(),
|
|
196
133
|
},
|
|
197
134
|
})
|
|
@@ -270,11 +207,6 @@ export async function recordDeployedSystemForModule(
|
|
|
270
207
|
machineId: infrastructure?.machineId ?? null,
|
|
271
208
|
serviceId: infrastructure?.serviceId ?? null,
|
|
272
209
|
vmid: Number.isNaN(vmid as number) ? null : vmid,
|
|
273
|
-
// Seed canonical size from requires.system (seed-once; preserved across
|
|
274
|
-
// re-deploys). Only meaningful for celilo-provisioned instances.
|
|
275
|
-
cpu: decl.resources.cpu ?? null,
|
|
276
|
-
memory: decl.resources.memory ?? null,
|
|
277
|
-
disk: decl.resources.disk ?? null,
|
|
278
210
|
});
|
|
279
211
|
|
|
280
212
|
return getModuleSystems(moduleId, db);
|
|
@@ -347,10 +279,6 @@ export function backfillModuleSystems(db: DbClient): string[] {
|
|
|
347
279
|
machineId: infra.machineId ?? null,
|
|
348
280
|
serviceId: infra.serviceId ?? null,
|
|
349
281
|
vmid: vmid != null && !Number.isNaN(vmid) ? vmid : null,
|
|
350
|
-
// Seed canonical size from requires.system for upgraded deployments.
|
|
351
|
-
cpu: decl.resources.cpu ?? null,
|
|
352
|
-
memory: decl.resources.memory ?? null,
|
|
353
|
-
disk: decl.resources.disk ?? null,
|
|
354
282
|
});
|
|
355
283
|
backfilled.push(infra.moduleId);
|
|
356
284
|
}
|
|
@@ -122,6 +122,41 @@ describe('checkGitHygiene', () => {
|
|
|
122
122
|
}
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
test('skips the stale gate for version_source: changeset (apps order by +N)', () => {
|
|
126
|
+
const repo = makeTempRepo();
|
|
127
|
+
try {
|
|
128
|
+
// Same stale shape that fails above — src committed after manifest.
|
|
129
|
+
writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n');
|
|
130
|
+
repo.exec('git add . && git commit -q -m "manifest"');
|
|
131
|
+
writeFileSync(join(repo.dir, 'install.sh'), '#!/bin/sh\n');
|
|
132
|
+
repo.exec('git add . && git commit -q -m "src after manifest"');
|
|
133
|
+
|
|
134
|
+
const stale = checkGitHygiene(repo.dir, 'changeset').find(
|
|
135
|
+
(c) => c.name === 'stale-version drift',
|
|
136
|
+
);
|
|
137
|
+
expect(stale?.status).toBe('ok'); // gate N/A, not a fail
|
|
138
|
+
expect(stale?.message).toContain('changeset');
|
|
139
|
+
} finally {
|
|
140
|
+
repo.cleanup();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('skips the stale gate for version_source: pin', () => {
|
|
145
|
+
const repo = makeTempRepo();
|
|
146
|
+
try {
|
|
147
|
+
writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n');
|
|
148
|
+
repo.exec('git add . && git commit -q -m "manifest"');
|
|
149
|
+
writeFileSync(join(repo.dir, 'install.sh'), '#!/bin/sh\n');
|
|
150
|
+
repo.exec('git add . && git commit -q -m "src after manifest"');
|
|
151
|
+
|
|
152
|
+
const stale = checkGitHygiene(repo.dir, 'pin').find((c) => c.name === 'stale-version drift');
|
|
153
|
+
expect(stale?.status).toBe('ok');
|
|
154
|
+
expect(stale?.message).toContain('pin');
|
|
155
|
+
} finally {
|
|
156
|
+
repo.cleanup();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
125
160
|
test('warn on dirty working tree', () => {
|
|
126
161
|
const repo = makeTempRepo();
|
|
127
162
|
try {
|
|
@@ -85,30 +85,49 @@ export function checkModuleStale(moduleDir: string): StalenessIssue | null {
|
|
|
85
85
|
* directory tarball, etc.) — git operations return null/empty and we
|
|
86
86
|
* skip the check.
|
|
87
87
|
*/
|
|
88
|
-
export function checkGitHygiene(modulePath: string): Check[] {
|
|
88
|
+
export function checkGitHygiene(modulePath: string, versionSourceKind?: string): Check[] {
|
|
89
89
|
const checks: Check[] = [];
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
` src commit: ${stale.lastSrcCommit.slice(0, 12)}`,
|
|
100
|
-
` manifest.yml commit: ${stale.lastManifestCommit.slice(0, 12)}`,
|
|
101
|
-
' Bump manifest.yml#version (semver change), or touch it',
|
|
102
|
-
' (release-only — auto-revision picks the next +N), then commit.',
|
|
103
|
-
].join('\n'),
|
|
104
|
-
});
|
|
105
|
-
} else {
|
|
91
|
+
// The source-after-manifest stale gate guards a HAND-MAINTAINED module's
|
|
92
|
+
// version (the default — `recipe` kind / unset version_source). For
|
|
93
|
+
// changeset-versioned modules the version is authored via .changeset/ +
|
|
94
|
+
// `celilo module version` and ordered by the +N revision, so source moving
|
|
95
|
+
// past the manifest is the NORMAL case, not drift; for pin modules the version
|
|
96
|
+
// is checked against the upstream resolver, not git ancestry. Skip the gate for
|
|
97
|
+
// both (v2/MODULE_VERSIONING.md / ISS-0151).
|
|
98
|
+
if (versionSourceKind === 'changeset' || versionSourceKind === 'pin') {
|
|
106
99
|
checks.push({
|
|
107
100
|
category: 'git_hygiene',
|
|
108
101
|
name: 'stale-version drift',
|
|
109
102
|
status: 'ok',
|
|
110
|
-
message:
|
|
103
|
+
message:
|
|
104
|
+
versionSourceKind === 'changeset'
|
|
105
|
+
? 'version_source: changeset — changeset-authored version, ordered by +N; source-after-manifest gate N/A'
|
|
106
|
+
: 'version_source: pin — version checked against the upstream resolver, not git ancestry',
|
|
111
107
|
});
|
|
108
|
+
} else {
|
|
109
|
+
const stale = checkModuleStale(modulePath);
|
|
110
|
+
if (stale) {
|
|
111
|
+
checks.push({
|
|
112
|
+
category: 'git_hygiene',
|
|
113
|
+
name: 'stale-version drift',
|
|
114
|
+
status: 'fail',
|
|
115
|
+
message: [
|
|
116
|
+
'src commits past last manifest.yml change',
|
|
117
|
+
` src commit: ${stale.lastSrcCommit.slice(0, 12)}`,
|
|
118
|
+
` manifest.yml commit: ${stale.lastManifestCommit.slice(0, 12)}`,
|
|
119
|
+
' Bump manifest.yml#version (semver change), or touch it',
|
|
120
|
+
' (release-only — auto-revision picks the next +N), then commit.',
|
|
121
|
+
].join('\n'),
|
|
122
|
+
});
|
|
123
|
+
} else {
|
|
124
|
+
checks.push({
|
|
125
|
+
category: 'git_hygiene',
|
|
126
|
+
name: 'stale-version drift',
|
|
127
|
+
status: 'ok',
|
|
128
|
+
message: 'manifest.yml is current with respect to module src',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
112
131
|
}
|
|
113
132
|
|
|
114
133
|
try {
|
|
@@ -21,6 +21,7 @@ export { checkWorkspaceDeps, defaultFetchNpmMetadata } from './workspace-deps';
|
|
|
21
21
|
|
|
22
22
|
interface RawManifest extends ManifestForCapabilityCheck {
|
|
23
23
|
celilo_contract?: string;
|
|
24
|
+
version_source?: { kind?: string };
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/**
|
|
@@ -67,7 +68,7 @@ export async function runChecks(
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
checks.push(...(await checkWorkspaceDeps(modulePath, fetcher)));
|
|
70
|
-
checks.push(...checkGitHygiene(modulePath));
|
|
71
|
+
checks.push(...checkGitHygiene(modulePath, manifest?.version_source?.kind));
|
|
71
72
|
checks.push(await checkTypeScriptBuild(modulePath, { noBuild: options.noBuild }));
|
|
72
73
|
|
|
73
74
|
return checks;
|