@celilo/cli 0.5.0-alpha.7 → 0.5.0-alpha.9
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/api-clients/proxmox.test.ts +78 -0
- package/src/api-clients/proxmox.ts +96 -1
- package/src/cli/command-registry.ts +32 -3
- package/src/cli/commands/backup-delete.ts +10 -7
- package/src/cli/commands/backup-import.ts +11 -8
- package/src/cli/commands/backup-restore.ts +11 -8
- package/src/cli/commands/events.ts +8 -3
- package/src/cli/commands/machine-add.ts +178 -163
- package/src/cli/commands/machine-remove.ts +10 -7
- package/src/cli/commands/module-config.test.ts +78 -0
- package/src/cli/commands/module-config.ts +18 -3
- package/src/cli/commands/module-import.ts +9 -5
- package/src/cli/commands/module-remove.ts +20 -9
- package/src/cli/commands/module-status.ts +15 -0
- package/src/cli/commands/module-upgrade.ts +10 -6
- package/src/cli/commands/proxmox-node-list.ts +101 -0
- package/src/cli/commands/proxmox-template-selection.ts +16 -15
- package/src/cli/commands/service-add-digitalocean.ts +120 -109
- package/src/cli/commands/service-add-proxmox.ts +275 -260
- package/src/cli/commands/service-reconfigure.ts +171 -153
- package/src/cli/commands/service-remove.ts +19 -13
- package/src/cli/commands/service-verify.ts +9 -10
- package/src/cli/commands/storage-add-local.ts +120 -107
- package/src/cli/commands/storage-add-s3.ts +145 -131
- package/src/cli/commands/storage-remove.ts +11 -8
- package/src/cli/commands/system-init.ts +119 -128
- package/src/cli/completion.ts +15 -0
- package/src/cli/index.ts +25 -0
- package/src/cli/service-credential.ts +54 -0
- package/src/services/bus-interview.ts +232 -0
- package/src/services/deploy-validation.test.ts +52 -2
- package/src/services/deploy-validation.ts +27 -36
- package/src/services/fleet-checks.test.ts +13 -0
- package/src/services/fleet-checks.ts +15 -0
- package/src/services/module-config.ts +12 -0
- package/src/services/module-deploy.ts +7 -6
- package/src/services/placement-reconcile.test.ts +86 -0
- package/src/services/placement-reconcile.ts +108 -0
- package/src/services/programmatic-responder.ts +34 -0
- package/src/services/terminal-responder.ts +113 -0
- package/src/templates/generator.test.ts +30 -0
- package/src/templates/generator.ts +86 -31
|
@@ -8,6 +8,7 @@ import { readFileSync } from 'node:fs';
|
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { getDb } from '../../db/client';
|
|
10
10
|
import type { NetworkZone } from '../../db/schema';
|
|
11
|
+
import { askText, withInterviewSession } from '../../services/bus-interview';
|
|
11
12
|
import {
|
|
12
13
|
detectMachineInfo,
|
|
13
14
|
detectMachineInfoLocal,
|
|
@@ -23,9 +24,8 @@ import type {
|
|
|
23
24
|
MachineRole,
|
|
24
25
|
NetworkInterface,
|
|
25
26
|
} from '../../types/infrastructure';
|
|
26
|
-
import { celiloIntro, celiloOutro
|
|
27
|
+
import { celiloIntro, celiloOutro } from '../prompts';
|
|
27
28
|
import type { CommandResult } from '../types';
|
|
28
|
-
import { validateIpAddress, validateRequired } from '../validators';
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Auto-detect SSH private key from system configuration
|
|
@@ -97,183 +97,198 @@ export async function handleMachineAdd(
|
|
|
97
97
|
args: string[],
|
|
98
98
|
flags: Record<string, boolean | string> = {},
|
|
99
99
|
): Promise<CommandResult> {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
// IP address: from positional arg, --ip flag, or prompt
|
|
108
|
-
if (args[0] && /^\d+\.\d+\.\d+\.\d+$/.test(args[0])) {
|
|
109
|
-
ipAddress = args[0];
|
|
110
|
-
} else if (typeof flags.ip === 'string') {
|
|
111
|
-
ipAddress = flags.ip;
|
|
112
|
-
} else {
|
|
113
|
-
ipAddress = await promptText({
|
|
114
|
-
message: 'Machine IP address:',
|
|
115
|
-
validate: validateIpAddress,
|
|
116
|
-
});
|
|
117
|
-
}
|
|
100
|
+
// The two prompts below are bus interviews (ISS-0127), so `machine add` is
|
|
101
|
+
// drivable headlessly via `celilo events respond --values` / `events reply`.
|
|
102
|
+
// SSH auth uses a key file (--ssh-key-file or auto-detected) — never a
|
|
103
|
+
// prompted password — so there is no service credential to resolve here.
|
|
104
|
+
// `withInterviewSession` renders bus questions locally when stdin is a TTY.
|
|
105
|
+
const scope = 'machine-add';
|
|
118
106
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return {
|
|
123
|
-
success: false,
|
|
124
|
-
error: `Machine with IP ${ipAddress} already exists (hostname: ${existing.hostname}, zone: ${existing.zone})`,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
107
|
+
return withInterviewSession(async () => {
|
|
108
|
+
try {
|
|
109
|
+
celiloIntro('Add Machine to Pool');
|
|
127
110
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
// never prompts (it would hang a non-interactive bootstrap postinst).
|
|
132
|
-
const isLocal = ipAddress === '127.0.0.1' || flags.local === true;
|
|
133
|
-
// An explicit --zone overrides inference (needed for the local box,
|
|
134
|
-
// whose 127.0.0.1 matches no zone subnet, and useful before a firewall
|
|
135
|
-
// has provided the target zone).
|
|
136
|
-
const zoneOverride = typeof flags.zone === 'string' ? (flags.zone as NetworkZone) : undefined;
|
|
137
|
-
|
|
138
|
-
// SSH user: irrelevant for a local machine (local connection); else
|
|
139
|
-
// from flag, default to 'root', or prompt.
|
|
140
|
-
if (isLocal) {
|
|
141
|
-
sshUser = 'root';
|
|
142
|
-
} else if (typeof flags['ssh-user'] === 'string') {
|
|
143
|
-
sshUser = flags['ssh-user'];
|
|
144
|
-
} else {
|
|
145
|
-
sshUser = await promptText({
|
|
146
|
-
message: 'SSH username:',
|
|
147
|
-
defaultValue: 'root',
|
|
148
|
-
placeholder: 'root',
|
|
149
|
-
validate: validateRequired('SSH username'),
|
|
150
|
-
});
|
|
151
|
-
}
|
|
111
|
+
// Hybrid mode: use flags for what's provided, prompt for what's missing
|
|
112
|
+
let ipAddress: string;
|
|
113
|
+
let sshUser: string;
|
|
152
114
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
115
|
+
// IP address: from positional arg, --ip flag, or prompt
|
|
116
|
+
if (args[0] && /^\d+\.\d+\.\d+\.\d+$/.test(args[0])) {
|
|
117
|
+
ipAddress = args[0];
|
|
118
|
+
} else if (typeof flags.ip === 'string') {
|
|
119
|
+
ipAddress = flags.ip;
|
|
120
|
+
} else {
|
|
121
|
+
ipAddress = await askText({
|
|
122
|
+
scope,
|
|
123
|
+
key: 'ip_address',
|
|
124
|
+
message: 'Machine IP address',
|
|
125
|
+
placeholder: 'e.g., 192.168.1.100',
|
|
126
|
+
required: true,
|
|
127
|
+
pattern: '^\\d+\\.\\d+\\.\\d+\\.\\d+$',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check for duplicate IP
|
|
132
|
+
const existing = await getMachineByIp(ipAddress);
|
|
133
|
+
if (existing) {
|
|
134
|
+
return {
|
|
135
|
+
success: false,
|
|
136
|
+
error: `Machine with IP ${ipAddress} already exists (hostname: ${existing.hostname}, zone: ${existing.zone})`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The local management box (127.0.0.1, or explicit --local) deploys
|
|
141
|
+
// over Ansible's local connection — no SSH, no key, no connectivity
|
|
142
|
+
// test. Determine this BEFORE the SSH-user step so the local path
|
|
143
|
+
// never prompts (it would hang a non-interactive bootstrap postinst).
|
|
144
|
+
const isLocal = ipAddress === '127.0.0.1' || flags.local === true;
|
|
145
|
+
// An explicit --zone overrides inference (needed for the local box,
|
|
146
|
+
// whose 127.0.0.1 matches no zone subnet, and useful before a firewall
|
|
147
|
+
// has provided the target zone).
|
|
148
|
+
const zoneOverride = typeof flags.zone === 'string' ? (flags.zone as NetworkZone) : undefined;
|
|
149
|
+
|
|
150
|
+
// SSH user: irrelevant for a local machine (local connection); else
|
|
151
|
+
// from flag, default to 'root', or prompt.
|
|
152
|
+
if (isLocal) {
|
|
153
|
+
sshUser = 'root';
|
|
154
|
+
} else if (typeof flags['ssh-user'] === 'string') {
|
|
155
|
+
sshUser = flags['ssh-user'];
|
|
181
156
|
} else {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
157
|
+
sshUser = await askText({
|
|
158
|
+
scope,
|
|
159
|
+
key: 'ssh_user',
|
|
160
|
+
message: 'SSH username',
|
|
161
|
+
defaultValue: 'root',
|
|
162
|
+
placeholder: 'root',
|
|
163
|
+
required: true,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let detectedInfo: DetectedMachineInfo;
|
|
168
|
+
let zone: NetworkZone;
|
|
169
|
+
let interfaces: NetworkInterface[];
|
|
170
|
+
let role: MachineRole;
|
|
171
|
+
let sshKey: string;
|
|
172
|
+
|
|
173
|
+
if (isLocal) {
|
|
174
|
+
console.log('\nLocal machine — detecting locally (no SSH)...');
|
|
175
|
+
detectedInfo = await detectMachineInfoLocal();
|
|
176
|
+
const net = await detectNetworkInterfacesLocal();
|
|
177
|
+
interfaces = net.interfaces;
|
|
178
|
+
role = net.role;
|
|
179
|
+
// The box you're installing on is, by definition, on the internal LAN.
|
|
180
|
+
zone = zoneOverride ?? 'internal';
|
|
181
|
+
sshKey = ''; // local connection — no key needed
|
|
182
|
+
console.log(
|
|
183
|
+
`✓ Local: ${detectedInfo.hostname} — ${detectedInfo.hardware.cpu_cores} cores, ` +
|
|
184
|
+
`${detectedInfo.hardware.memory_mb} MB, ${detectedInfo.hardware.disk_gb} GB (zone ${zone}, connection local)\n`,
|
|
185
|
+
);
|
|
186
|
+
} else {
|
|
187
|
+
// SSH key: from flag, auto-detect, or error
|
|
188
|
+
let sshKeyPath: string;
|
|
189
|
+
if (typeof flags['ssh-key-file'] === 'string') {
|
|
190
|
+
sshKeyPath = flags['ssh-key-file'];
|
|
191
|
+
const expandedPath = sshKeyPath.replace(/^~/, process.env.HOME || '~');
|
|
192
|
+
if (!existsSync(expandedPath)) {
|
|
193
|
+
return { success: false, error: `SSH key file not found: ${expandedPath}` };
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
// Auto-detect SSH key from system config
|
|
197
|
+
const detectedKeyPath = findSshPrivateKey();
|
|
198
|
+
if (!detectedKeyPath) {
|
|
199
|
+
return {
|
|
200
|
+
success: false,
|
|
201
|
+
error:
|
|
202
|
+
'Cannot find SSH private key.\n\n' +
|
|
203
|
+
'The ssh.public_key system config is set, but no matching private key was found in ~/.ssh/\n\n' +
|
|
204
|
+
'Specify manually with: --ssh-key-file ~/.ssh/id_ed25519',
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
sshKeyPath = detectedKeyPath;
|
|
208
|
+
console.log(`Using SSH key: ${sshKeyPath}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Expand tilde in path
|
|
212
|
+
const expandedKeyPath = sshKeyPath.replace(/^~/, process.env.HOME || '~');
|
|
213
|
+
|
|
214
|
+
// Read SSH key content
|
|
215
|
+
sshKey = readFileSync(expandedKeyPath, 'utf8');
|
|
216
|
+
|
|
217
|
+
console.log('\nTesting SSH connection...');
|
|
218
|
+
|
|
219
|
+
// Test SSH connectivity
|
|
220
|
+
const canConnect = await testSshConnection(ipAddress, sshUser, expandedKeyPath);
|
|
221
|
+
if (!canConnect) {
|
|
185
222
|
return {
|
|
186
223
|
success: false,
|
|
187
|
-
error:
|
|
188
|
-
'Cannot find SSH private key.\n\n' +
|
|
189
|
-
'The ssh.public_key system config is set, but no matching private key was found in ~/.ssh/\n\n' +
|
|
190
|
-
'Specify manually with: --ssh-key-file ~/.ssh/id_ed25519',
|
|
224
|
+
error: `Cannot connect to ${sshUser}@${ipAddress} with provided SSH key`,
|
|
191
225
|
};
|
|
192
226
|
}
|
|
193
|
-
sshKeyPath = detectedKeyPath;
|
|
194
|
-
console.log(`Using SSH key: ${sshKeyPath}`);
|
|
195
|
-
}
|
|
196
227
|
|
|
197
|
-
|
|
198
|
-
const expandedKeyPath = sshKeyPath.replace(/^~/, process.env.HOME || '~');
|
|
228
|
+
console.log('✓ SSH connection successful\n');
|
|
199
229
|
|
|
200
|
-
|
|
201
|
-
sshKey = readFileSync(expandedKeyPath, 'utf8');
|
|
230
|
+
console.log('Detecting machine information...');
|
|
202
231
|
|
|
203
|
-
|
|
232
|
+
// Auto-detect machine info
|
|
233
|
+
detectedInfo = await detectMachineInfo(ipAddress, sshUser, expandedKeyPath);
|
|
204
234
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
};
|
|
212
|
-
|
|
235
|
+
console.log('✓ Machine detected:');
|
|
236
|
+
console.log(` Hostname: ${detectedInfo.hostname}`);
|
|
237
|
+
console.log(` OS: ${detectedInfo.osInfo}`);
|
|
238
|
+
console.log(
|
|
239
|
+
` CPU: ${detectedInfo.hardware.cpu_cores} cores (${detectedInfo.hardware.arch || 'unknown'})`,
|
|
240
|
+
);
|
|
241
|
+
console.log(` Memory: ${detectedInfo.hardware.memory_mb} MB`);
|
|
242
|
+
console.log(` Disk: ${detectedInfo.hardware.disk_gb} GB\n`);
|
|
213
243
|
|
|
214
|
-
|
|
244
|
+
// Zone: explicit override, else infer from IP.
|
|
245
|
+
console.log('Detecting network zone...');
|
|
246
|
+
zone = zoneOverride ?? (await detectZoneFromIp(ipAddress));
|
|
247
|
+
console.log(`✓ Zone: ${zone}\n`);
|
|
215
248
|
|
|
216
|
-
|
|
249
|
+
// Detect network interfaces and classify machine
|
|
250
|
+
console.log('Detecting network interfaces...');
|
|
251
|
+
const net = await detectNetworkInterfaces(ipAddress, sshUser, expandedKeyPath);
|
|
252
|
+
interfaces = net.interfaces;
|
|
253
|
+
role = net.role;
|
|
217
254
|
|
|
218
|
-
|
|
219
|
-
|
|
255
|
+
console.log(`✓ Role: ${role}`);
|
|
256
|
+
for (const iface of interfaces) {
|
|
257
|
+
console.log(` ${iface.name}: ${iface.ipAddress} (${iface.zone})`);
|
|
258
|
+
}
|
|
259
|
+
console.log('');
|
|
260
|
+
}
|
|
220
261
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
262
|
+
// Add machine to pool
|
|
263
|
+
const earmark = typeof flags.earmark === 'string' ? flags.earmark : undefined;
|
|
264
|
+
const machine = await addMachine({
|
|
265
|
+
hostname: detectedInfo.hostname,
|
|
266
|
+
zone,
|
|
267
|
+
ipAddress,
|
|
268
|
+
sshUser,
|
|
269
|
+
sshKey,
|
|
270
|
+
hardware: detectedInfo.hardware,
|
|
271
|
+
role,
|
|
272
|
+
interfaces,
|
|
273
|
+
assignedModuleIds: [],
|
|
274
|
+
earmarkedModule: earmark || null,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const earmarkNote = earmark ? `\n Earmarked for: ${earmark}` : '';
|
|
278
|
+
const roleNote = role === 'router' ? ` (router - ${interfaces.length} interfaces)` : '';
|
|
279
|
+
celiloOutro(
|
|
280
|
+
`Machine '${detectedInfo.hostname}' added successfully!\n\nDetails:\n Zone: ${zone}${roleNote}\n IP: ${ipAddress}\n Hardware: ${detectedInfo.hardware.cpu_cores} cores, ${detectedInfo.hardware.memory_mb} MB RAM, ${detectedInfo.hardware.disk_gb} GB disk${earmarkNote}\n\nNext steps:\n - List machines: celilo machine list\n - Check status: celilo machine status ${detectedInfo.hostname}`,
|
|
226
281
|
);
|
|
227
|
-
console.log(` Memory: ${detectedInfo.hardware.memory_mb} MB`);
|
|
228
|
-
console.log(` Disk: ${detectedInfo.hardware.disk_gb} GB\n`);
|
|
229
|
-
|
|
230
|
-
// Zone: explicit override, else infer from IP.
|
|
231
|
-
console.log('Detecting network zone...');
|
|
232
|
-
zone = zoneOverride ?? (await detectZoneFromIp(ipAddress));
|
|
233
|
-
console.log(`✓ Zone: ${zone}\n`);
|
|
234
|
-
|
|
235
|
-
// Detect network interfaces and classify machine
|
|
236
|
-
console.log('Detecting network interfaces...');
|
|
237
|
-
const net = await detectNetworkInterfaces(ipAddress, sshUser, expandedKeyPath);
|
|
238
|
-
interfaces = net.interfaces;
|
|
239
|
-
role = net.role;
|
|
240
|
-
|
|
241
|
-
console.log(`✓ Role: ${role}`);
|
|
242
|
-
for (const iface of interfaces) {
|
|
243
|
-
console.log(` ${iface.name}: ${iface.ipAddress} (${iface.zone})`);
|
|
244
|
-
}
|
|
245
|
-
console.log('');
|
|
246
|
-
}
|
|
247
282
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
assignedModuleIds: [],
|
|
260
|
-
earmarkedModule: earmark || null,
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
const earmarkNote = earmark ? `\n Earmarked for: ${earmark}` : '';
|
|
264
|
-
const roleNote = role === 'router' ? ` (router - ${interfaces.length} interfaces)` : '';
|
|
265
|
-
celiloOutro(
|
|
266
|
-
`Machine '${detectedInfo.hostname}' added successfully!\n\nDetails:\n Zone: ${zone}${roleNote}\n IP: ${ipAddress}\n Hardware: ${detectedInfo.hardware.cpu_cores} cores, ${detectedInfo.hardware.memory_mb} MB RAM, ${detectedInfo.hardware.disk_gb} GB disk${earmarkNote}\n\nNext steps:\n - List machines: celilo machine list\n - Check status: celilo machine status ${detectedInfo.hostname}`,
|
|
267
|
-
);
|
|
268
|
-
|
|
269
|
-
return {
|
|
270
|
-
success: true,
|
|
271
|
-
message: `Added machine: ${machine.id}`,
|
|
272
|
-
};
|
|
273
|
-
} catch (error) {
|
|
274
|
-
return {
|
|
275
|
-
success: false,
|
|
276
|
-
error: `Failed to add machine: ${error instanceof Error ? error.message : String(error)}`,
|
|
277
|
-
};
|
|
278
|
-
}
|
|
283
|
+
return {
|
|
284
|
+
success: true,
|
|
285
|
+
message: `Added machine: ${machine.id}`,
|
|
286
|
+
};
|
|
287
|
+
} catch (error) {
|
|
288
|
+
return {
|
|
289
|
+
success: false,
|
|
290
|
+
error: `Failed to add machine: ${error instanceof Error ? error.message : String(error)}`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
});
|
|
279
294
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Remove a machine from the machine pool
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import
|
|
6
|
+
import { askConfirm, withInterviewSession } from '../../services/bus-interview';
|
|
7
7
|
import { getMachineByHostname, getMachineByIp, removeMachine } from '../../services/machine-pool';
|
|
8
8
|
import { celiloIntro, celiloOutro } from '../prompts';
|
|
9
9
|
import type { CommandResult } from '../types';
|
|
@@ -61,13 +61,16 @@ export async function handleMachineRemove(
|
|
|
61
61
|
|
|
62
62
|
// Confirm deletion
|
|
63
63
|
if (!flags.force) {
|
|
64
|
-
const confirmed = await
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
const confirmed = await withInterviewSession(() =>
|
|
65
|
+
askConfirm({
|
|
66
|
+
scope: `machine:${hostname}`,
|
|
67
|
+
key: 'remove',
|
|
68
|
+
message: `Remove machine '${hostname}' (${machine.ipAddress})?`,
|
|
69
|
+
defaultValue: false,
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
68
72
|
|
|
69
|
-
if (
|
|
70
|
-
p.cancel('Operation cancelled');
|
|
73
|
+
if (!confirmed) {
|
|
71
74
|
return { success: false, error: 'Cancelled by user' };
|
|
72
75
|
}
|
|
73
76
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recurrence gate for ISS-0069: `config set` must never accept an
|
|
3
|
+
* infrastructure-managed key and then have the deploy silently override it.
|
|
4
|
+
* Framework-managed (`source: infrastructure`) keys are rejected at set time;
|
|
5
|
+
* operator-settable keys are honored.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
9
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { type DbClient, getDb } from '../../db/client';
|
|
13
|
+
import { modules } from '../../db/schema';
|
|
14
|
+
import { handleModuleConfigSet } from './module-config';
|
|
15
|
+
|
|
16
|
+
describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
|
|
17
|
+
let tempDir: string;
|
|
18
|
+
let db: DbClient;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
tempDir = mkdtempSync(join(tmpdir(), 'celilo-module-config-'));
|
|
22
|
+
process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
|
|
23
|
+
db = getDb();
|
|
24
|
+
db.insert(modules)
|
|
25
|
+
.values({
|
|
26
|
+
id: 'testmod',
|
|
27
|
+
name: 'Test Module',
|
|
28
|
+
sourcePath: tempDir,
|
|
29
|
+
version: '1.0.0',
|
|
30
|
+
manifestData: {
|
|
31
|
+
variables: {
|
|
32
|
+
owns: [
|
|
33
|
+
{ name: 'vmid', type: 'integer', source: 'infrastructure' },
|
|
34
|
+
{ name: 'target_node', type: 'string', source: 'infrastructure' },
|
|
35
|
+
{ name: 'app_port', type: 'integer' },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
.run();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
45
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('rejects an infrastructure-managed key (no silent accept-then-override)', async () => {
|
|
49
|
+
const result = await handleModuleConfigSet(['testmod', 'vmid', '203']);
|
|
50
|
+
expect(result.success).toBe(false);
|
|
51
|
+
if (!result.success) {
|
|
52
|
+
expect(result.error).toContain('infrastructure-managed');
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('rejects target_node and points at the real lever', async () => {
|
|
57
|
+
const result = await handleModuleConfigSet(['testmod', 'target_node', 'node3']);
|
|
58
|
+
expect(result.success).toBe(false);
|
|
59
|
+
if (!result.success) {
|
|
60
|
+
expect(result.error).toContain('not operator-settable');
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('accepts a normal operator-settable key', async () => {
|
|
65
|
+
const result = await handleModuleConfigSet(['testmod', 'app_port', '8080']);
|
|
66
|
+
expect(result.success).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('still rejects an undeclared key, and omits infra keys from the valid-keys hint', async () => {
|
|
70
|
+
const result = await handleModuleConfigSet(['testmod', 'nope', 'x']);
|
|
71
|
+
expect(result.success).toBe(false);
|
|
72
|
+
if (!result.success) {
|
|
73
|
+
expect(result.error).toContain('Invalid config key');
|
|
74
|
+
expect(result.error).toContain('app_port');
|
|
75
|
+
expect(result.error).not.toContain('vmid'); // infra keys filtered from the hint
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -57,17 +57,32 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
|
|
|
57
57
|
// Validate key against manifest
|
|
58
58
|
const manifest = module.manifestData as Record<string, unknown>;
|
|
59
59
|
const variables = manifest.variables as
|
|
60
|
-
| { owns?: Array<{ name: string; required?: boolean; default?: string }> }
|
|
60
|
+
| { owns?: Array<{ name: string; required?: boolean; default?: string; source?: string }> }
|
|
61
61
|
| undefined;
|
|
62
62
|
const declaredVars = variables?.owns || [];
|
|
63
63
|
|
|
64
64
|
// Check if key is declared in manifest
|
|
65
65
|
const declaredVar = declaredVars.find((v) => v.name === key);
|
|
66
66
|
if (!declaredVar) {
|
|
67
|
-
const
|
|
67
|
+
const settableKeys = declaredVars
|
|
68
|
+
.filter((v) => v.source !== 'infrastructure')
|
|
69
|
+
.map((v) => v.name)
|
|
70
|
+
.join(', ');
|
|
68
71
|
return {
|
|
69
72
|
success: false,
|
|
70
|
-
error: `Invalid config key '${key}' for module ${moduleId}.\n\nValid keys: ${
|
|
73
|
+
error: `Invalid config key '${key}' for module ${moduleId}.\n\nValid keys: ${settableKeys || '(none declared)'}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ISS-0069: reject infrastructure-managed keys at SET time rather than
|
|
78
|
+
// accepting-then-silently-overriding them at deploy. `source: infrastructure`
|
|
79
|
+
// variables (vmid, target_ip, target_node, gateway, vlan, lxc_template) are
|
|
80
|
+
// derived by the deploy (IPAM allocates vmid/IP; the container service supplies
|
|
81
|
+
// node/template/gateway/vlan), so a value set here would be ignored.
|
|
82
|
+
if (declaredVar.source === 'infrastructure') {
|
|
83
|
+
return {
|
|
84
|
+
success: false,
|
|
85
|
+
error: `'${key}' is infrastructure-managed by celilo (source: infrastructure) — not operator-settable.\nThe deploy derives it automatically, so a value set here would be silently ignored.\n • node placement: set the service default for NEW deploys (celilo service reconfigure); move an existing container with 'celilo proxmox migrate' (ISS-0062).\n • vmid / IP: auto-allocated by IPAM.`,
|
|
71
86
|
};
|
|
72
87
|
}
|
|
73
88
|
|
|
@@ -33,8 +33,8 @@ import {
|
|
|
33
33
|
computeAspectScopeHash,
|
|
34
34
|
recordAspectApproval,
|
|
35
35
|
} from '../../services/aspect-approvals';
|
|
36
|
+
import { askConfirm, withInterviewSession } from '../../services/bus-interview';
|
|
36
37
|
import { getArg, getFlag, hasFlag } from '../parser';
|
|
37
|
-
import { promptConfirm } from '../prompts';
|
|
38
38
|
import type { CommandResult } from '../types';
|
|
39
39
|
import { generateTypesForImportedModule } from './module-types';
|
|
40
40
|
|
|
@@ -148,10 +148,14 @@ export async function handleAspectApprovalAfterImport(args: {
|
|
|
148
148
|
// mangles multi-line message arguments.
|
|
149
149
|
process.stderr.write(`\n${scopeMsg}\n\n`);
|
|
150
150
|
|
|
151
|
-
const accepted = await
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
151
|
+
const accepted = await withInterviewSession(() =>
|
|
152
|
+
askConfirm({
|
|
153
|
+
scope: `module-import:${moduleId}`,
|
|
154
|
+
key: 'approve_aspect',
|
|
155
|
+
message: 'Approve this base-module aspect?',
|
|
156
|
+
defaultValue: false,
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
155
159
|
|
|
156
160
|
if (!accepted) {
|
|
157
161
|
// Roll back the import — DB delete cascades to configs/secrets/etc.
|
|
@@ -16,6 +16,7 @@ import { runNamedHook } from '../../hooks/run-named-hook';
|
|
|
16
16
|
import { deallocateForModule } from '../../ipam/auto-allocator';
|
|
17
17
|
import { ModuleManifestSchema } from '../../manifest/schema';
|
|
18
18
|
import { executeBuildWithProgress } from '../../services/build-stream';
|
|
19
|
+
import { askConfirm, withInterviewSession } from '../../services/bus-interview';
|
|
19
20
|
import {
|
|
20
21
|
emitUninstallCompleted,
|
|
21
22
|
emitUninstallFailed,
|
|
@@ -24,7 +25,7 @@ import {
|
|
|
24
25
|
import { getContainerService, getServiceCredentials } from '../../services/container-service';
|
|
25
26
|
import { completeOperation, failOperation, startOperation } from '../../services/module-operations';
|
|
26
27
|
import { getArg, hasFlag, validateRequiredArgs } from '../parser';
|
|
27
|
-
import { log
|
|
28
|
+
import { log } from '../prompts';
|
|
28
29
|
import type { CommandResult } from '../types';
|
|
29
30
|
|
|
30
31
|
/**
|
|
@@ -186,11 +187,16 @@ async function performModuleRemove(
|
|
|
186
187
|
// re-run `celilo module run-hook <id> on_uninstall` manually.
|
|
187
188
|
log.warn('Non-interactive context detected; continuing removal despite hook failure');
|
|
188
189
|
} else {
|
|
189
|
-
const proceed = await
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
190
|
+
const proceed = await withInterviewSession(() =>
|
|
191
|
+
askConfirm({
|
|
192
|
+
scope: `module-remove:${moduleId}`,
|
|
193
|
+
key: 'continue_after_hook_failure',
|
|
194
|
+
message:
|
|
195
|
+
'on_uninstall hook failed. Continue removing the module anyway? ' +
|
|
196
|
+
'(some external state may not be cleaned up)',
|
|
197
|
+
defaultValue: false,
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
194
200
|
if (!proceed) {
|
|
195
201
|
return { success: false, error: 'Removal cancelled after on_uninstall failure' };
|
|
196
202
|
}
|
|
@@ -211,9 +217,14 @@ async function performModuleRemove(
|
|
|
211
217
|
if (infra && hasTerraformState) {
|
|
212
218
|
// Module has infrastructure — need to destroy it
|
|
213
219
|
if (!force) {
|
|
214
|
-
const confirmed = await
|
|
215
|
-
|
|
216
|
-
|
|
220
|
+
const confirmed = await withInterviewSession(() =>
|
|
221
|
+
askConfirm({
|
|
222
|
+
scope: `module-remove:${moduleId}`,
|
|
223
|
+
key: 'destroy_infrastructure',
|
|
224
|
+
message: `Module '${moduleId}' has deployed infrastructure. This will run terraform destroy to remove it. Continue?`,
|
|
225
|
+
defaultValue: false,
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
217
228
|
|
|
218
229
|
if (!confirmed) {
|
|
219
230
|
return {
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import { eq } from 'drizzle-orm';
|
|
6
6
|
import { getDb } from '../../db/client';
|
|
7
7
|
import { capabilities, moduleConfigs, modules, secrets } from '../../db/schema';
|
|
8
|
+
import { getModuleSystems } from '../../services/deployed-systems';
|
|
9
|
+
import { formatPlacementLine, reconcilePlacement } from '../../services/placement-reconcile';
|
|
8
10
|
import { getArg, validateRequiredArgs } from '../parser';
|
|
9
11
|
import type { CommandResult } from '../types';
|
|
10
12
|
|
|
@@ -84,6 +86,19 @@ export async function handleModuleStatus(args: string[]): Promise<CommandResult>
|
|
|
84
86
|
}
|
|
85
87
|
sections.push(metadataLines.join('\n'));
|
|
86
88
|
|
|
89
|
+
// Section 1b: Placement (real node reconciled live from Proxmox — ISS-0060).
|
|
90
|
+
// Shows where each deployed system ACTUALLY lives, not the cached
|
|
91
|
+
// __infra_target_node config (which drifts). API-only modules (no deployed
|
|
92
|
+
// systems) skip this section; a Proxmox outage degrades to "node unknown".
|
|
93
|
+
const placements = await reconcilePlacement(getModuleSystems(moduleId, db));
|
|
94
|
+
if (placements.length > 0) {
|
|
95
|
+
const placementLines = ['Placement (live from Proxmox):'];
|
|
96
|
+
for (const p of placements) {
|
|
97
|
+
placementLines.push(` ${formatPlacementLine(p.system, p.resolution)}`);
|
|
98
|
+
}
|
|
99
|
+
sections.push(placementLines.join('\n'));
|
|
100
|
+
}
|
|
101
|
+
|
|
87
102
|
// Section 2: Configuration
|
|
88
103
|
if (configs.length > 0) {
|
|
89
104
|
const configLines = ['Configuration:'];
|