@celilo/cli 0.8.2 → 0.9.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/AGENTS.md +10 -18
- package/CELILO_CORE_MODULES.md +61 -0
- package/CELILO_SUBSYSTEMS.md +83 -0
- package/README.md +1539 -48
- package/drizzle/0012_module_systems_sizing.sql +3 -0
- package/drizzle/0013_dns_view_overrides.sql +1 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +5 -10
- package/src/capabilities/well-known.test.ts +12 -66
- package/src/capabilities/well-known.ts +11 -12
- package/src/cli/command-registry.ts +65 -1
- package/src/cli/commands/module-upgrade.test.ts +29 -0
- package/src/cli/commands/module-upgrade.ts +57 -24
- package/src/cli/commands/proxmox-instance-list.test.ts +77 -0
- package/src/cli/commands/proxmox-instance-list.ts +140 -0
- package/src/cli/commands/proxmox-instance-resize.ts +235 -0
- package/src/cli/commands/proxmox-node-list.ts +1 -34
- package/src/cli/commands/proxmox-resize-guards.test.ts +55 -0
- package/src/cli/commands/proxmox-resize-guards.ts +102 -0
- package/src/cli/commands/proxmox-service.ts +38 -0
- package/src/cli/completion.ts +11 -37
- package/src/cli/index.ts +15 -0
- package/src/cli/validators.test.ts +1 -206
- package/src/cli/validators.ts +0 -168
- package/src/db/schema.ts +21 -1
- package/src/hooks/capability-loader.ts +22 -0
- package/src/manifest/template-validator.test.ts +31 -1
- package/src/manifest/template-validator.ts +9 -0
- package/src/services/aspect-approvals.test.ts +52 -0
- package/src/services/aspect-approvals.ts +41 -8
- package/src/services/deployed-systems.test.ts +73 -1
- package/src/services/deployed-systems.ts +72 -0
- package/src/services/dns-internal-records.test.ts +76 -3
- package/src/services/dns-internal-records.ts +52 -3
- package/src/services/dns-provider-backfill.ts +15 -3
- package/src/services/fleet-checks.test.ts +18 -16
- package/src/services/machine-detector.ts +34 -12
- package/src/services/programmatic-responder.aspect.test.ts +157 -0
- package/src/services/programmatic-responder.ts +51 -0
- package/src/templates/generator.ts +49 -1
- package/src/utils/shell.test.ts +1 -163
- package/src/utils/shell.ts +0 -100
- package/src/validation/schemas.ts +0 -5
- package/src/variables/context.ts +36 -7
- package/CLI_USAGE.md +0 -433
- package/src/config/env.ts +0 -41
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE `dns_internal_records` ADD `zone_routable_ip` text;
|
|
@@ -85,6 +85,20 @@
|
|
|
85
85
|
"when": 1781481660000,
|
|
86
86
|
"tag": "0011_backups_name",
|
|
87
87
|
"breakpoints": true
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"idx": 12,
|
|
91
|
+
"version": "6",
|
|
92
|
+
"when": 1781481720000,
|
|
93
|
+
"tag": "0012_module_systems_sizing",
|
|
94
|
+
"breakpoints": true
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"idx": 13,
|
|
98
|
+
"version": "6",
|
|
99
|
+
"when": 1782456724000,
|
|
100
|
+
"tag": "0013_dns_view_overrides",
|
|
101
|
+
"breakpoints": true
|
|
88
102
|
}
|
|
89
103
|
]
|
|
90
|
-
}
|
|
104
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,15 +13,10 @@
|
|
|
13
13
|
"schemas/",
|
|
14
14
|
"tsconfig.json",
|
|
15
15
|
"AGENTS.md",
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
"keywords": [
|
|
19
|
-
"celilo",
|
|
20
|
-
"homelab",
|
|
21
|
-
"orchestration",
|
|
22
|
-
"ansible",
|
|
23
|
-
"terraform"
|
|
16
|
+
"CELILO_SUBSYSTEMS.md",
|
|
17
|
+
"CELILO_CORE_MODULES.md"
|
|
24
18
|
],
|
|
19
|
+
"keywords": ["celilo", "homelab", "orchestration", "ansible", "terraform"],
|
|
25
20
|
"license": "MIT",
|
|
26
21
|
"repository": {
|
|
27
22
|
"type": "git",
|
|
@@ -54,7 +49,7 @@
|
|
|
54
49
|
},
|
|
55
50
|
"dependencies": {
|
|
56
51
|
"@aws-sdk/client-s3": "^3.1024.0",
|
|
57
|
-
"@celilo/capabilities": "^0.
|
|
52
|
+
"@celilo/capabilities": "^0.6.0",
|
|
58
53
|
"@celilo/cli-display": "^0.1.9",
|
|
59
54
|
"@celilo/event-bus": "^0.1.7",
|
|
60
55
|
"@clack/prompts": "^1.1.0",
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
import {
|
|
3
3
|
WELL_KNOWN_CAPABILITIES,
|
|
4
|
-
getCapabilityDataSchema,
|
|
5
4
|
getSupportedCapabilities,
|
|
6
5
|
getWellKnownCapability,
|
|
7
6
|
isWellKnown,
|
|
@@ -27,7 +26,9 @@ describe('Well-Known Capabilities Registry', () => {
|
|
|
27
26
|
test('should have dns_internal capability', () => {
|
|
28
27
|
expect(WELL_KNOWN_CAPABILITIES.dns_internal).toBeDefined();
|
|
29
28
|
expect(WELL_KNOWN_CAPABILITIES.dns_internal.canonical_hostname).toBe('dns-int');
|
|
30
|
-
|
|
29
|
+
// ISS-0156: the resolver moved to a protected zone (dmz) so it can see
|
|
30
|
+
// protected-zone query sources for split-horizon views.
|
|
31
|
+
expect(WELL_KNOWN_CAPABILITIES.dns_internal.required_zone).toBe('dmz');
|
|
31
32
|
expect(WELL_KNOWN_CAPABILITIES.dns_internal.zone_enforced).toBe(true);
|
|
32
33
|
});
|
|
33
34
|
|
|
@@ -140,10 +141,11 @@ describe('Well-Known Capabilities Registry', () => {
|
|
|
140
141
|
expect(result.error).toContain("Capability 'auth' requires zone='secure'");
|
|
141
142
|
});
|
|
142
143
|
|
|
143
|
-
test('should validate correct zone for dns_internal', () => {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
test('should validate correct zone for dns_internal (dmz — ISS-0156)', () => {
|
|
145
|
+
expect(validateZoneRequirement('dns_internal', 'dmz').valid).toBe(true);
|
|
146
|
+
// The old `internal` placement is now rejected — the resolver must be in a
|
|
147
|
+
// protected zone to see protected-zone query sources for split-horizon.
|
|
148
|
+
expect(validateZoneRequirement('dns_internal', 'internal').valid).toBe(false);
|
|
147
149
|
});
|
|
148
150
|
|
|
149
151
|
test('should allow dns_registrar in any zone (zone_enforced=false)', () => {
|
|
@@ -163,64 +165,6 @@ describe('Well-Known Capabilities Registry', () => {
|
|
|
163
165
|
});
|
|
164
166
|
});
|
|
165
167
|
|
|
166
|
-
describe('getCapabilityDataSchema', () => {
|
|
167
|
-
test('should return data schema for public_web', () => {
|
|
168
|
-
const schema = getCapabilityDataSchema('public_web');
|
|
169
|
-
|
|
170
|
-
expect(schema).toEqual({
|
|
171
|
-
server: {
|
|
172
|
-
ip: {
|
|
173
|
-
primary: '$self:target_ip',
|
|
174
|
-
},
|
|
175
|
-
port: 443,
|
|
176
|
-
},
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
test('should return data schema for dns_registrar', () => {
|
|
181
|
-
const schema = getCapabilityDataSchema('dns_registrar');
|
|
182
|
-
|
|
183
|
-
expect(schema).toEqual({
|
|
184
|
-
provider: 'namecheap',
|
|
185
|
-
domains: '$self:domains',
|
|
186
|
-
supports: ['dynamic_dns_a_record'],
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test('should return data schema for auth', () => {
|
|
191
|
-
const schema = getCapabilityDataSchema('auth');
|
|
192
|
-
|
|
193
|
-
// Note: oidc.issuer_url was removed in MANIFEST_V2 D9 — auth providers
|
|
194
|
-
// (e.g. authentik) now derive their issuer URL in their own manifest
|
|
195
|
-
// and expose it through provides.capabilities[].data.
|
|
196
|
-
expect(schema).toEqual({
|
|
197
|
-
server: {
|
|
198
|
-
ip: {
|
|
199
|
-
primary: '$self:target_ip',
|
|
200
|
-
},
|
|
201
|
-
port: 9000,
|
|
202
|
-
},
|
|
203
|
-
});
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
test('should return cloned schema (not reference)', () => {
|
|
207
|
-
const schema1 = getCapabilityDataSchema('public_web');
|
|
208
|
-
const schema2 = getCapabilityDataSchema('public_web');
|
|
209
|
-
|
|
210
|
-
// Modify one schema to test cloning
|
|
211
|
-
// biome-ignore lint/suspicious/noExplicitAny: intentionally mutating to verify independent copies
|
|
212
|
-
(schema1 as any).server.port = 8080;
|
|
213
|
-
|
|
214
|
-
// Other schema should be unchanged
|
|
215
|
-
// biome-ignore lint/suspicious/noExplicitAny: accessing mutated property for comparison
|
|
216
|
-
expect((schema2 as any).server.port).toBe(443);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
test('should throw error for unknown capability', () => {
|
|
220
|
-
expect(() => getCapabilityDataSchema('unknown')).toThrow('Unknown capability: unknown');
|
|
221
|
-
});
|
|
222
|
-
});
|
|
223
|
-
|
|
224
168
|
describe('Security zone requirements', () => {
|
|
225
169
|
test('home lab public-facing services must be in DMZ', () => {
|
|
226
170
|
expect(WELL_KNOWN_CAPABILITIES.public_web.required_zone).toBe('dmz');
|
|
@@ -231,8 +175,10 @@ describe('Well-Known Capabilities Registry', () => {
|
|
|
231
175
|
expect(WELL_KNOWN_CAPABILITIES.database.required_zone).toBe('secure');
|
|
232
176
|
});
|
|
233
177
|
|
|
234
|
-
test('internal
|
|
235
|
-
|
|
178
|
+
test('the internal DNS resolver lives in the dmz (ISS-0156)', () => {
|
|
179
|
+
// Moved out of `internal`: the resolver must see protected-zone query
|
|
180
|
+
// sources for source-based split-horizon views (v2/INTERNAL_DNS_ZONE_VIEWS.md).
|
|
181
|
+
expect(WELL_KNOWN_CAPABILITIES.dns_internal.required_zone).toBe('dmz');
|
|
236
182
|
});
|
|
237
183
|
});
|
|
238
184
|
});
|
|
@@ -74,12 +74,20 @@ export const WELL_KNOWN_CAPABILITIES: Record<string, WellKnownCapability> = {
|
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
76
|
* dns_internal - Internal DNS resolver
|
|
77
|
-
* Example: Technitium
|
|
78
|
-
* Security:
|
|
77
|
+
* Example: Technitium / knot-unbound for split-horizon DNS
|
|
78
|
+
* Security: a PROTECTED zone (dmz), NOT `internal` (ISS-0156,
|
|
79
|
+
* v2/INTERNAL_DNS_ZONE_VIEWS.md). The resolver must see each querying client's
|
|
80
|
+
* real source IP to serve source-based split-horizon views; fw-main NATs
|
|
81
|
+
* protected↔`internal`, so an `internal`-placed resolver sees every protected
|
|
82
|
+
* query as fw-main's address and can't tell the zones apart. Placed in `dmz`
|
|
83
|
+
* (a protected zone — protected↔protected is not NAT'd) it sees real
|
|
84
|
+
* protected-zone sources; `internal` devices reach it via a firewall
|
|
85
|
+
* DNS-ingress DNAT. The operator accepted the modest posture change (the
|
|
86
|
+
* `internal` zone is itself un-managed). zone_enforced stays true — just to dmz.
|
|
79
87
|
*/
|
|
80
88
|
dns_internal: {
|
|
81
89
|
canonical_hostname: 'dns-int',
|
|
82
|
-
required_zone: '
|
|
90
|
+
required_zone: 'dmz',
|
|
83
91
|
zone_enforced: true,
|
|
84
92
|
data_schema: {
|
|
85
93
|
server: {
|
|
@@ -219,12 +227,3 @@ export function validateZoneRequirement(
|
|
|
219
227
|
|
|
220
228
|
return { valid: true };
|
|
221
229
|
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Get capability data schema with variables resolved
|
|
225
|
-
* Note: This returns the template schema - actual variable resolution happens during generation
|
|
226
|
-
*/
|
|
227
|
-
export function getCapabilityDataSchema(capabilityName: string): Record<string, unknown> {
|
|
228
|
-
const capability = getWellKnownCapability(capabilityName);
|
|
229
|
-
return structuredClone(capability.data_schema);
|
|
230
|
-
}
|
|
@@ -1090,7 +1090,7 @@ export const COMMANDS: CommandDef[] = [
|
|
|
1090
1090
|
},
|
|
1091
1091
|
{
|
|
1092
1092
|
name: 'proxmox',
|
|
1093
|
-
description: 'Proxmox cluster introspection (nodes, capacity)',
|
|
1093
|
+
description: 'Proxmox cluster introspection (nodes, capacity, instance sizing)',
|
|
1094
1094
|
subcommands: [
|
|
1095
1095
|
{
|
|
1096
1096
|
name: 'node',
|
|
@@ -1103,6 +1103,70 @@ export const COMMANDS: CommandDef[] = [
|
|
|
1103
1103
|
},
|
|
1104
1104
|
],
|
|
1105
1105
|
},
|
|
1106
|
+
{
|
|
1107
|
+
name: 'vm',
|
|
1108
|
+
description: 'celilo-provisioned VM sizing',
|
|
1109
|
+
subcommands: [
|
|
1110
|
+
{
|
|
1111
|
+
name: 'list',
|
|
1112
|
+
description: 'List celilo VMs with desired vs actual size',
|
|
1113
|
+
args: [{ name: 'service-id', description: 'Proxmox service (optional if only one)' }],
|
|
1114
|
+
},
|
|
1115
|
+
{
|
|
1116
|
+
name: 'resize',
|
|
1117
|
+
description: 'Resize a celilo VM (canonical size + reconcile)',
|
|
1118
|
+
args: [{ name: 'name', description: 'Instance name (module/hostname)' }],
|
|
1119
|
+
flags: [
|
|
1120
|
+
{ name: 'memory', description: 'New RAM in MB', takesValue: true },
|
|
1121
|
+
{ name: 'cpu', description: 'New vCPU count', takesValue: true },
|
|
1122
|
+
{ name: 'force', description: 'Override the node capacity check', takesValue: false },
|
|
1123
|
+
{
|
|
1124
|
+
name: 'allow-reboot',
|
|
1125
|
+
description: 'Approve the stop/start a resize needs',
|
|
1126
|
+
takesValue: false,
|
|
1127
|
+
},
|
|
1128
|
+
{ name: 'skip-backup', description: 'Skip the pre-resize backup', takesValue: false },
|
|
1129
|
+
{
|
|
1130
|
+
name: 'yes',
|
|
1131
|
+
description: 'Auto-approve reboot + backup (headless)',
|
|
1132
|
+
takesValue: false,
|
|
1133
|
+
},
|
|
1134
|
+
],
|
|
1135
|
+
},
|
|
1136
|
+
],
|
|
1137
|
+
},
|
|
1138
|
+
{
|
|
1139
|
+
name: 'ct',
|
|
1140
|
+
description: 'celilo-provisioned container (LXC) sizing',
|
|
1141
|
+
subcommands: [
|
|
1142
|
+
{
|
|
1143
|
+
name: 'list',
|
|
1144
|
+
description: 'List celilo containers with desired vs actual size',
|
|
1145
|
+
args: [{ name: 'service-id', description: 'Proxmox service (optional if only one)' }],
|
|
1146
|
+
},
|
|
1147
|
+
{
|
|
1148
|
+
name: 'resize',
|
|
1149
|
+
description: 'Resize a celilo container (canonical size + reconcile)',
|
|
1150
|
+
args: [{ name: 'name', description: 'Instance name (module/hostname)' }],
|
|
1151
|
+
flags: [
|
|
1152
|
+
{ name: 'memory', description: 'New RAM in MB', takesValue: true },
|
|
1153
|
+
{ name: 'cpu', description: 'New vCPU count', takesValue: true },
|
|
1154
|
+
{ name: 'force', description: 'Override the node capacity check', takesValue: false },
|
|
1155
|
+
{
|
|
1156
|
+
name: 'allow-reboot',
|
|
1157
|
+
description: 'Approve the stop/start a resize needs',
|
|
1158
|
+
takesValue: false,
|
|
1159
|
+
},
|
|
1160
|
+
{ name: 'skip-backup', description: 'Skip the pre-resize backup', takesValue: false },
|
|
1161
|
+
{
|
|
1162
|
+
name: 'yes',
|
|
1163
|
+
description: 'Auto-approve reboot + backup (headless)',
|
|
1164
|
+
takesValue: false,
|
|
1165
|
+
},
|
|
1166
|
+
],
|
|
1167
|
+
},
|
|
1168
|
+
],
|
|
1169
|
+
},
|
|
1106
1170
|
],
|
|
1107
1171
|
},
|
|
1108
1172
|
{
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ModuleManifest } from '../../manifest/schema';
|
|
2
3
|
import {
|
|
3
4
|
type PollCandidate,
|
|
5
|
+
needsPreUpgradeBackup,
|
|
4
6
|
pickAutoUpgrade,
|
|
5
7
|
pickUpgradePolicy,
|
|
6
8
|
selectPollTargets,
|
|
7
9
|
} from './module-upgrade';
|
|
8
10
|
|
|
11
|
+
/** Minimal manifest fixture; only `hooks` matters for the backup gate. */
|
|
12
|
+
function manifest(hooks?: ModuleManifest['hooks']): ModuleManifest {
|
|
13
|
+
return { id: 'forgejo', name: 'Forgejo', version: '0.3.0', hooks } as ModuleManifest;
|
|
14
|
+
}
|
|
15
|
+
const withBackupHook = manifest({ on_backup: { script: './scripts/backup.ts', timeout: 300000 } });
|
|
16
|
+
const noBackupHook = manifest({ on_install: { script: './scripts/setup.ts', timeout: 180000 } });
|
|
17
|
+
|
|
9
18
|
describe('pickUpgradePolicy (ISS-0138 — config override > manifest default > by-semver)', () => {
|
|
10
19
|
test('operator config wins over the manifest default', () => {
|
|
11
20
|
expect(pickUpgradePolicy('always-safe', 'always-fast')).toBe('always-safe');
|
|
@@ -80,3 +89,23 @@ describe('selectPollTargets (ISS-0139 — opted-in + a newer registry version)',
|
|
|
80
89
|
expect(targets.map((t) => t.moduleId)).toEqual(['a']);
|
|
81
90
|
});
|
|
82
91
|
});
|
|
92
|
+
|
|
93
|
+
describe('needsPreUpgradeBackup (ISS-0168 — gate on the TARGET version manifest)', () => {
|
|
94
|
+
// The regression: the upgrade that FIRST adds on_backup must still back up.
|
|
95
|
+
// The installed version lacked the hook; the target (passed here) adds it.
|
|
96
|
+
test('safe posture + target adds on_backup → backs up', () => {
|
|
97
|
+
expect(needsPreUpgradeBackup('safe', withBackupHook)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('safe posture + target has no on_backup → does not back up', () => {
|
|
101
|
+
expect(needsPreUpgradeBackup('safe', noBackupHook)).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('fast posture (patch/revision) never backs up, even with on_backup', () => {
|
|
105
|
+
expect(needsPreUpgradeBackup('fast', withBackupHook)).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('safe posture + no hooks block at all → does not back up', () => {
|
|
109
|
+
expect(needsPreUpgradeBackup('safe', manifest(undefined))).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -21,7 +21,11 @@ import { modules } from '../../db/schema';
|
|
|
21
21
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
22
22
|
import { RegistryClient } from '../../registry/client';
|
|
23
23
|
import { createModuleBackup } from '../../services/backup-create';
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
type DeployPosture,
|
|
26
|
+
type UpgradePolicy,
|
|
27
|
+
resolveDeployPosture,
|
|
28
|
+
} from '../../services/deploy-posture';
|
|
25
29
|
import { runModuleHealthCheck } from '../../services/health-runner';
|
|
26
30
|
import { getModuleConfigValue } from '../../services/module-config';
|
|
27
31
|
import { deployModule } from '../../services/module-deploy';
|
|
@@ -62,6 +66,23 @@ export function pickAutoUpgrade(
|
|
|
62
66
|
return fromManifest ?? false;
|
|
63
67
|
}
|
|
64
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Pure (Rule 10): does this upgrade need a pre-deploy backup? Yes IFF the
|
|
71
|
+
* posture is "safe" (a minor/major bump) AND the TARGET version's manifest
|
|
72
|
+
* declares an on_backup hook.
|
|
73
|
+
*
|
|
74
|
+
* The `targetManifest` MUST be the version being upgraded TO (read back after
|
|
75
|
+
* the def is refreshed), never the installed one — gating on the installed
|
|
76
|
+
* manifest skips the backup on the very upgrade that introduces the hook
|
|
77
|
+
* (ISS-0168).
|
|
78
|
+
*/
|
|
79
|
+
export function needsPreUpgradeBackup(
|
|
80
|
+
posture: DeployPosture,
|
|
81
|
+
targetManifest: ModuleManifest,
|
|
82
|
+
): boolean {
|
|
83
|
+
return posture === 'safe' && Boolean(targetManifest.hooks?.on_backup);
|
|
84
|
+
}
|
|
85
|
+
|
|
65
86
|
export interface PollCandidate {
|
|
66
87
|
moduleId: string;
|
|
67
88
|
installed: string;
|
|
@@ -116,13 +137,34 @@ async function upgradeOneModule(
|
|
|
116
137
|
flags: Record<string, string | boolean>,
|
|
117
138
|
): Promise<CommandResult> {
|
|
118
139
|
const moduleId = mod.id;
|
|
119
|
-
const manifest = mod.manifestData as ModuleManifest;
|
|
120
140
|
|
|
121
|
-
//
|
|
141
|
+
// Update FIRST (refresh stored def). Every downstream decision — the
|
|
142
|
+
// backup gate and the posture policy — must consult the TARGET version's
|
|
143
|
+
// manifest, not the installed one. Gating on the pre-update manifest skipped
|
|
144
|
+
// the on_backup hook on the very upgrade that INTRODUCES it (e.g. a stateful
|
|
145
|
+
// module that first ships backup support doesn't protect its own next
|
|
146
|
+
// upgrade) — ISS-0168.
|
|
147
|
+
const updated = await fetchAndUpdate(client, moduleId, targetVersion, db, flags);
|
|
148
|
+
if (updated.status !== 'success') {
|
|
149
|
+
const why = updated.status === 'failed' ? updated.error : updated.reason;
|
|
150
|
+
return { success: false, error: `Update failed for ${moduleId}: ${why}` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Read the target def back: fetchAndUpdate persisted it but doesn't return
|
|
154
|
+
// the manifest. Fall back to the pre-update manifest only if the row somehow
|
|
155
|
+
// vanished (it won't on the success path).
|
|
156
|
+
const updatedRow = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
157
|
+
const targetManifest =
|
|
158
|
+
(updatedRow?.manifestData as ModuleManifest | undefined) ??
|
|
159
|
+
(mod.manifestData as ModuleManifest);
|
|
160
|
+
|
|
161
|
+
// Posture. Version delta is installed→target; the policy comes from the
|
|
162
|
+
// TARGET manifest (the version being installed declares its upgrade risk),
|
|
163
|
+
// with an operator config override still winning.
|
|
122
164
|
const configPolicy = getModuleConfigValue(moduleId, 'upgrade_policy');
|
|
123
165
|
const modulePolicy = pickUpgradePolicy(
|
|
124
166
|
typeof configPolicy?.value === 'string' ? configPolicy.value : undefined,
|
|
125
|
-
(
|
|
167
|
+
(targetManifest as ModuleManifest & { upgrade_policy?: string }).upgrade_policy,
|
|
126
168
|
);
|
|
127
169
|
// Per-release deploy_posture override lives in the .netapp release metadata;
|
|
128
170
|
// reading it requires fetching the package first. Deferred — the classifier
|
|
@@ -135,27 +177,18 @@ async function upgradeOneModule(
|
|
|
135
177
|
});
|
|
136
178
|
log.info(`Upgrading ${moduleId} ${mod.version} → ${targetVersion} (${posture} — ${reason})`);
|
|
137
179
|
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (posture === 'safe') {
|
|
147
|
-
if (manifest.hooks?.on_backup) {
|
|
148
|
-
const backup = await createModuleBackup(moduleId);
|
|
149
|
-
if (!backup.success) {
|
|
150
|
-
return {
|
|
151
|
-
success: false,
|
|
152
|
-
error: `Pre-upgrade backup failed for ${moduleId}: ${backup.error}`,
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
log.success(`Backed up ${moduleId} before deploy`);
|
|
156
|
-
} else {
|
|
157
|
-
log.warn(`${moduleId} has no on_backup hook — proceeding without a pre-upgrade backup`);
|
|
180
|
+
// Safe → back up first, gated on the TARGET manifest's on_backup hook.
|
|
181
|
+
if (needsPreUpgradeBackup(posture, targetManifest)) {
|
|
182
|
+
const backup = await createModuleBackup(moduleId);
|
|
183
|
+
if (!backup.success) {
|
|
184
|
+
return {
|
|
185
|
+
success: false,
|
|
186
|
+
error: `Pre-upgrade backup failed for ${moduleId}: ${backup.error}`,
|
|
187
|
+
};
|
|
158
188
|
}
|
|
189
|
+
log.success(`Backed up ${moduleId} before deploy`);
|
|
190
|
+
} else if (posture === 'safe') {
|
|
191
|
+
log.warn(`${moduleId} has no on_backup hook — proceeding without a pre-upgrade backup`);
|
|
159
192
|
}
|
|
160
193
|
|
|
161
194
|
// Deploy (idempotent).
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ProxmoxClusterResource } from '../../api-clients/proxmox';
|
|
3
|
+
import type { ProvisionedSystem } from '../../services/deployed-systems';
|
|
4
|
+
import { joinInstanceRows } from './proxmox-instance-list';
|
|
5
|
+
|
|
6
|
+
const GB = 1024 * 1024 * 1024;
|
|
7
|
+
const MB = 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
function sys(p: Partial<ProvisionedSystem> & { vmid: number }): ProvisionedSystem {
|
|
10
|
+
return {
|
|
11
|
+
moduleId: 'forgejo-builder',
|
|
12
|
+
name: 'main',
|
|
13
|
+
hostname: 'forgejo-builder',
|
|
14
|
+
ipv4Address: '10.0.10.20',
|
|
15
|
+
zone: 'dmz',
|
|
16
|
+
serviceId: 'svc',
|
|
17
|
+
cpu: 4,
|
|
18
|
+
memory: 8192,
|
|
19
|
+
disk: 80,
|
|
20
|
+
...p,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('joinInstanceRows (ISS-0150)', () => {
|
|
25
|
+
test('joins a VM: desired from system, actual from the qemu guest', () => {
|
|
26
|
+
const systems = [sys({ vmid: 208 })];
|
|
27
|
+
const resources: ProxmoxClusterResource[] = [
|
|
28
|
+
{
|
|
29
|
+
type: 'qemu',
|
|
30
|
+
vmid: 208,
|
|
31
|
+
node: 'node3',
|
|
32
|
+
status: 'running',
|
|
33
|
+
maxcpu: 4,
|
|
34
|
+
maxmem: 16 * GB,
|
|
35
|
+
maxdisk: 100 * GB,
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
const rows = joinInstanceRows(systems, resources, 'vm');
|
|
39
|
+
expect(rows).toHaveLength(1);
|
|
40
|
+
expect(rows[0].desired).toEqual({ cpu: 4, memMb: 8192, diskGb: 80 });
|
|
41
|
+
expect(rows[0].actual).toEqual({ cpu: 4, memMb: 16384, diskGb: 100 });
|
|
42
|
+
expect(rows[0].node).toBe('node3');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('kind filters by Proxmox guest type — a qemu guest never shows under ct', () => {
|
|
46
|
+
const systems = [sys({ vmid: 208 })];
|
|
47
|
+
const resources: ProxmoxClusterResource[] = [
|
|
48
|
+
{ type: 'qemu', vmid: 208, node: 'node3', maxcpu: 4, maxmem: 8 * GB },
|
|
49
|
+
];
|
|
50
|
+
expect(joinInstanceRows(systems, resources, 'ct')).toHaveLength(0);
|
|
51
|
+
expect(joinInstanceRows(systems, resources, 'vm')).toHaveLength(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('an lxc system shows under ct', () => {
|
|
55
|
+
const systems = [sys({ moduleId: 'caddy', vmid: 202 })];
|
|
56
|
+
const resources: ProxmoxClusterResource[] = [
|
|
57
|
+
{
|
|
58
|
+
type: 'lxc',
|
|
59
|
+
vmid: 202,
|
|
60
|
+
node: 'node2',
|
|
61
|
+
status: 'running',
|
|
62
|
+
maxcpu: 1,
|
|
63
|
+
maxmem: 512 * MB,
|
|
64
|
+
maxdisk: 10 * GB,
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
const rows = joinInstanceRows(systems, resources, 'ct');
|
|
68
|
+
expect(rows).toHaveLength(1);
|
|
69
|
+
expect(rows[0].module).toBe('caddy');
|
|
70
|
+
expect(rows[0].actual).toEqual({ cpu: 1, memMb: 512, diskGb: 10 });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a system absent from the cluster is omitted', () => {
|
|
74
|
+
const systems = [sys({ vmid: 999 })];
|
|
75
|
+
expect(joinInstanceRows(systems, [], 'vm')).toHaveLength(0);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo proxmox vm list` / `celilo proxmox ct list` — celilo-provisioned
|
|
3
|
+
* Proxmox instances with their **desired** size (canonical `module_systems`
|
|
4
|
+
* state, ISS-0150) next to their **actual** size (live from the Proxmox API). A
|
|
5
|
+
* desired≠actual row means a resize is pending a reconcile/redeploy.
|
|
6
|
+
*
|
|
7
|
+
* Read-only sibling of `proxmox node list`; the foundation the `resize` verb
|
|
8
|
+
* (ISS-0150 P2) builds on. See apps/celilo/designs/PROXMOX_INSTANCE_SIZING.md.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
ProxmoxClient,
|
|
13
|
+
type ProxmoxClusterResource,
|
|
14
|
+
type ProxmoxCredentials,
|
|
15
|
+
} from '../../api-clients/proxmox';
|
|
16
|
+
import { getDb } from '../../db/client';
|
|
17
|
+
import { getServiceCredentials, listContainerServices } from '../../services/container-service';
|
|
18
|
+
import { type ProvisionedSystem, getProvisionedSystems } from '../../services/deployed-systems';
|
|
19
|
+
import { celiloIntro } from '../prompts';
|
|
20
|
+
import type { CommandResult } from '../types';
|
|
21
|
+
import { resolveProxmoxService } from './proxmox-service';
|
|
22
|
+
|
|
23
|
+
const BYTES_PER_MB = 1024 * 1024;
|
|
24
|
+
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
export type InstanceKind = 'vm' | 'ct';
|
|
27
|
+
|
|
28
|
+
/** Proxmox `type` for each celilo instance kind. */
|
|
29
|
+
const PROXMOX_TYPE: Record<InstanceKind, string> = { vm: 'qemu', ct: 'lxc' };
|
|
30
|
+
|
|
31
|
+
export interface InstanceRow {
|
|
32
|
+
module: string;
|
|
33
|
+
name: string;
|
|
34
|
+
vmid: number;
|
|
35
|
+
node: string;
|
|
36
|
+
status: string;
|
|
37
|
+
desired: { cpu: number | null; memMb: number | null; diskGb: number | null };
|
|
38
|
+
/** null when the instance is in module_systems but not (yet) on the cluster. */
|
|
39
|
+
actual: { cpu: number; memMb: number; diskGb: number } | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Pure join of celilo's provisioned systems against live Proxmox guests, for one
|
|
44
|
+
* instance kind. Keyed by vmid; the Proxmox guest `type` decides vm vs ct
|
|
45
|
+
* membership (a celilo system row doesn't itself record the kind).
|
|
46
|
+
*/
|
|
47
|
+
export function joinInstanceRows(
|
|
48
|
+
systems: ProvisionedSystem[],
|
|
49
|
+
resources: ProxmoxClusterResource[],
|
|
50
|
+
kind: InstanceKind,
|
|
51
|
+
): InstanceRow[] {
|
|
52
|
+
const wantType = PROXMOX_TYPE[kind];
|
|
53
|
+
const byVmid = new Map<number, ProxmoxClusterResource>();
|
|
54
|
+
for (const r of resources) {
|
|
55
|
+
if (r.type === wantType && typeof r.vmid === 'number') byVmid.set(r.vmid, r);
|
|
56
|
+
}
|
|
57
|
+
const rows: InstanceRow[] = [];
|
|
58
|
+
for (const s of systems) {
|
|
59
|
+
if (s.vmid == null) continue;
|
|
60
|
+
const guest = byVmid.get(s.vmid);
|
|
61
|
+
if (!guest) continue; // not a guest of this kind (other kind, or gone from cluster)
|
|
62
|
+
rows.push({
|
|
63
|
+
module: s.moduleId,
|
|
64
|
+
name: s.name,
|
|
65
|
+
vmid: s.vmid,
|
|
66
|
+
node: guest.node ?? '—',
|
|
67
|
+
status: guest.status ?? '—',
|
|
68
|
+
desired: { cpu: s.cpu, memMb: s.memory, diskGb: s.disk },
|
|
69
|
+
actual:
|
|
70
|
+
guest.maxcpu != null && guest.maxmem != null
|
|
71
|
+
? {
|
|
72
|
+
cpu: guest.maxcpu,
|
|
73
|
+
memMb: Math.round(guest.maxmem / BYTES_PER_MB),
|
|
74
|
+
diskGb: guest.maxdisk != null ? Math.round(guest.maxdisk / BYTES_PER_GB) : 0,
|
|
75
|
+
}
|
|
76
|
+
: null,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return rows;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function fmtSize(cpu: number | null, memMb: number | null, diskGb: number | null): string {
|
|
83
|
+
const c = cpu != null ? `${cpu}c` : '—';
|
|
84
|
+
const m = memMb != null ? `${(memMb / 1024).toFixed(0)}G` : '—';
|
|
85
|
+
const d = diskGb != null ? `${diskGb}G` : '—';
|
|
86
|
+
return `${c}/${m}/${d}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function handleProxmoxInstanceList(
|
|
90
|
+
kind: InstanceKind,
|
|
91
|
+
args: string[],
|
|
92
|
+
): Promise<CommandResult> {
|
|
93
|
+
celiloIntro(kind === 'vm' ? 'Proxmox VMs' : 'Proxmox containers');
|
|
94
|
+
|
|
95
|
+
const resolved = resolveProxmoxService(await listContainerServices(), args[0]);
|
|
96
|
+
if ('error' in resolved) {
|
|
97
|
+
console.log(`✗ ${resolved.error}`);
|
|
98
|
+
return { success: false, error: resolved.error };
|
|
99
|
+
}
|
|
100
|
+
const { service } = resolved;
|
|
101
|
+
|
|
102
|
+
const creds = (await getServiceCredentials(service.id)) as ProxmoxCredentials;
|
|
103
|
+
const result = await new ProxmoxClient(creds).clusterResources();
|
|
104
|
+
if (!result.success) {
|
|
105
|
+
console.log(`✗ Could not reach Proxmox (${service.serviceId}): ${result.message}`);
|
|
106
|
+
return { success: false, error: result.message };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const rows = joinInstanceRows(getProvisionedSystems(getDb()), result.data, kind);
|
|
110
|
+
if (rows.length === 0) {
|
|
111
|
+
console.log(`No celilo-provisioned ${kind === 'vm' ? 'VMs' : 'containers'} found.`);
|
|
112
|
+
return { success: true, message: 'none' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
console.log(`Service: ${service.serviceId} (${service.name})\n`);
|
|
116
|
+
console.log(
|
|
117
|
+
'MODULE VMID NODE STATUS DESIRED(c/m/d) ACTUAL(c/m/d) DRIFT',
|
|
118
|
+
);
|
|
119
|
+
console.log(
|
|
120
|
+
'────────────────────────────────────────────────────────────────────────────────────',
|
|
121
|
+
);
|
|
122
|
+
for (const r of rows) {
|
|
123
|
+
const desired = fmtSize(r.desired.cpu, r.desired.memMb, r.desired.diskGb);
|
|
124
|
+
const actual = r.actual ? fmtSize(r.actual.cpu, r.actual.memMb, r.actual.diskGb) : '—';
|
|
125
|
+
// Only flag drift on a real mismatch — a null desired dimension means
|
|
126
|
+
// "unset / not yet seeded", not a pending resize.
|
|
127
|
+
const drift =
|
|
128
|
+
r.actual &&
|
|
129
|
+
((r.desired.cpu != null && r.desired.cpu !== r.actual.cpu) ||
|
|
130
|
+
(r.desired.memMb != null && r.desired.memMb !== r.actual.memMb) ||
|
|
131
|
+
(r.desired.diskGb != null && r.desired.diskGb !== r.actual.diskGb))
|
|
132
|
+
? '⚠ resize pending'
|
|
133
|
+
: '';
|
|
134
|
+
console.log(
|
|
135
|
+
`${r.module.padEnd(19)} ${String(r.vmid).padEnd(6)} ${r.node.padEnd(8)} ${r.status.padEnd(9)} ${desired.padEnd(16)} ${actual.padEnd(15)} ${drift}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
console.log('');
|
|
139
|
+
return { success: true, message: `${rows.length} ${kind}(s)` };
|
|
140
|
+
}
|