elestio 1.0.3 → 1.2.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.
@@ -0,0 +1,589 @@
1
+ import { apiRequest } from '../api.js';
2
+ import { loadConfig } from '../config.js';
3
+ import { log, colors, formatTable, outputJson, formatPrice } from '../utils.js';
4
+ import {
5
+ CLUSTER_TEMPLATE_IDS, CLUSTER_MAX_NODES,
6
+ minClusterNodes, supportsClustering, supportsMultiMaster
7
+ } from '../constants.js';
8
+ import { getTemplates } from './templates.js';
9
+ import { doAction } from './actions.js';
10
+ import { listServicesRaw, deleteService, getServiceDetails } from './services.js';
11
+
12
+ const APPID = 'CloudVM';
13
+
14
+ function requireProject(projectId) {
15
+ const pid = projectId || loadConfig().defaultProject;
16
+ if (!pid) {
17
+ throw new Error('Project ID required. Use --project or set a default with: elestio config --set-default-project <id>');
18
+ }
19
+ return String(pid);
20
+ }
21
+
22
+ // ── Read ──
23
+
24
+ export async function listClustersRaw(projectId = null, includeDeleted = false) {
25
+ const response = await apiRequest('/api/clusters/getClusters', 'POST', {
26
+ appid: APPID,
27
+ projectId: requireProject(projectId),
28
+ isActiveCluster: !includeDeleted
29
+ });
30
+
31
+ if (response.status === 'KO') throw new Error(response.message || 'Failed to list clusters');
32
+ return response.clusters || [];
33
+ }
34
+
35
+ export async function listClusters(projectId = null, json = false) {
36
+ const clusters = await listClustersRaw(projectId);
37
+
38
+ if (json) { outputJson(clusters); return clusters; }
39
+ if (clusters.length === 0) {
40
+ log('info', 'No clusters in this project. Create one with: elestio deploy <template> --cluster');
41
+ return [];
42
+ }
43
+
44
+ const rows = clusters.map(c => ({
45
+ id: c.id,
46
+ templateName: c.templateName || 'N/A',
47
+ mode: c.replicationMode || 'N/A',
48
+ nodes: `${c.totalNodes ?? c.nbNodes ?? '?'}`,
49
+ status: c.status || 'N/A',
50
+ cname: c.cname || 'pending',
51
+ cost: c.totalCost ? formatPrice(c.totalCost) : 'N/A'
52
+ }));
53
+
54
+ console.log(`\n${colors.bold}Clusters (${clusters.length})${colors.reset}\n`);
55
+ console.log(formatTable(rows, [
56
+ { key: 'id', label: 'ID' },
57
+ { key: 'templateName', label: 'Software' },
58
+ { key: 'mode', label: 'Mode' },
59
+ { key: 'nodes', label: 'Nodes' },
60
+ { key: 'status', label: 'Status' },
61
+ { key: 'cname', label: 'CNAME' },
62
+ { key: 'cost', label: 'Cost' }
63
+ ]));
64
+ console.log('');
65
+ return clusters;
66
+ }
67
+
68
+ /**
69
+ * The API keys cluster lookups on the primary node's provider server ID, not
70
+ * on the cluster ID, so resolve one from the other.
71
+ */
72
+ export async function getClusterInfo(clusterId, projectId = null) {
73
+ const clusters = await listClustersRaw(projectId);
74
+ const cluster = clusters.find(c => String(c.id) === String(clusterId));
75
+ if (!cluster) throw new Error(`Cluster ${clusterId} not found in this project`);
76
+
77
+ const response = await apiRequest('/api/clusters/getClusterInfos', 'POST', {
78
+ appid: APPID,
79
+ projectId: requireProject(projectId),
80
+ serviceId: String(cluster.primaryProviderServerID)
81
+ });
82
+
83
+ if (response.status === 'KO') throw new Error(response.message || 'Failed to get cluster info');
84
+ return { ...cluster, ...(response.clusters || {}) };
85
+ }
86
+
87
+ export async function showCluster(clusterId, projectId = null, json = false) {
88
+ const info = await getClusterInfo(clusterId, projectId);
89
+ const nodes = await listNodesRaw(info, projectId);
90
+
91
+ if (json) { outputJson({ ...info, nodes }); return info; }
92
+
93
+ console.log(`\n${colors.bold}Cluster ${info.id} - ${info.templateName || 'N/A'}${colors.reset}\n`);
94
+ console.log(` Status: ${info.status || 'N/A'} (${info.deploymentStatus || 'N/A'})`);
95
+ console.log(` Mode: ${info.replicationMode || 'N/A'}`);
96
+ console.log(` Nodes: ${info.totalNodes ?? info.nbNodes ?? nodes.length}`);
97
+ console.log(` CNAME: ${info.cname || 'pending'}`);
98
+ console.log(` Primary: vmID ${info.primaryProviderServerID || 'N/A'}`);
99
+ console.log(` Provider: ${info.provider || 'N/A'} / ${info.datacenter || 'N/A'}`);
100
+ console.log(` Firewall: ${info.isFirewallActivated ? 'enabled' : 'disabled'}`);
101
+ console.log(` Protected: ${info.isProtected ? 'yes' : 'no'}`);
102
+ console.log(` Failover: ${info.isFailoverEnabled ? 'automatic' : 'manual only'}`);
103
+ if (info.totalCost) console.log(` Cost: ${formatPrice(info.totalCost)}`);
104
+
105
+ if (nodes.length > 0) {
106
+ console.log(`\n${colors.bold}Nodes${colors.reset}\n`);
107
+ console.log(formatTable(nodeRows(nodes, info), NODE_COLUMNS));
108
+ }
109
+ console.log('');
110
+ return info;
111
+ }
112
+
113
+ /**
114
+ * Replicas carry the primary's serverID in their clusterID field, not the
115
+ * cluster's own ID. getActivesNodesByClusterID works the same way and only
116
+ * returns providerServerIDs, while promote needs the full node record, so the
117
+ * nodes are picked out of the project's services instead.
118
+ */
119
+ export function selectClusterNodes(services, cluster) {
120
+ const primary = services.find(s =>
121
+ (cluster.primaryServerID && String(s.id) === String(cluster.primaryServerID)) ||
122
+ String(s.vmID) === String(cluster.primaryProviderServerID)
123
+ );
124
+ if (!primary) return [];
125
+
126
+ const replicas = services.filter(s => s !== primary && String(s.clusterID) === String(primary.id));
127
+ return [primary, ...replicas];
128
+ }
129
+
130
+ export async function listNodesRaw(cluster, projectId = null) {
131
+ const services = await listServicesRaw(requireProject(projectId));
132
+ return selectClusterNodes(services, cluster);
133
+ }
134
+
135
+ const NODE_COLUMNS = [
136
+ { key: 'role', label: 'Role' },
137
+ { key: 'vmID', label: 'vmID' },
138
+ { key: 'displayName', label: 'Name' },
139
+ { key: 'status', label: 'Status' },
140
+ { key: 'ipv4', label: 'IP' },
141
+ { key: 'datacenter', label: 'Region' }
142
+ ];
143
+
144
+ function nodeRows(nodes, info) {
145
+ const primaryVmId = info ? String(info.primaryProviderServerID) : null;
146
+ return nodes.map(n => ({
147
+ role: String(n.vmID) === primaryVmId ? 'primary' : 'replica',
148
+ vmID: n.vmID ?? 'N/A',
149
+ displayName: n.displayName || n.serverName || 'N/A',
150
+ status: n.status || 'N/A',
151
+ ipv4: n.ipv4 || 'pending',
152
+ datacenter: n.datacenter || 'N/A'
153
+ }));
154
+ }
155
+
156
+ export async function listNodes(clusterId, projectId = null, json = false) {
157
+ const info = await getClusterInfo(clusterId, projectId);
158
+ const nodes = await listNodesRaw(info, projectId);
159
+
160
+ if (json) { outputJson(nodes); return nodes; }
161
+ if (nodes.length === 0) { log('info', `No active nodes on cluster ${clusterId}`); return []; }
162
+
163
+ console.log(`\n${colors.bold}Nodes of cluster ${clusterId}${colors.reset}\n`);
164
+ console.log(formatTable(nodeRows(nodes, info), NODE_COLUMNS));
165
+ console.log('');
166
+ return nodes;
167
+ }
168
+
169
+ // ── Actions ──
170
+
171
+ async function doActionOnCluster(clusterId, action) {
172
+ const response = await apiRequest('/api/clusters/DoActionOnCluster', 'POST', {
173
+ clusterID: String(clusterId),
174
+ action
175
+ });
176
+
177
+ if (response.status === 'KO') throw new Error(response.message || `Action "${action}" failed`);
178
+ return response;
179
+ }
180
+
181
+ export async function lockCluster(clusterId) {
182
+ const result = await doActionOnCluster(clusterId, 'lock');
183
+ log('success', `Cluster ${clusterId} locked (termination protection enabled)`);
184
+ return result;
185
+ }
186
+
187
+ export async function unlockCluster(clusterId) {
188
+ const result = await doActionOnCluster(clusterId, 'unlock');
189
+ log('success', `Cluster ${clusterId} unlocked`);
190
+ return result;
191
+ }
192
+
193
+ const TOGGLES = { on: true, enable: true, true: true, off: false, disable: false, false: false };
194
+
195
+ export function parseToggle(value) {
196
+ const key = String(value ?? '').toLowerCase();
197
+ if (!(key in TOGGLES)) throw new Error('Expected on|off');
198
+ return TOGGLES[key];
199
+ }
200
+
201
+ /**
202
+ * handleFailover does not fail over: it is the dashboard's automatic-failover
203
+ * switch, and it sets isFailoverEnabled to !currentStatus. The wanted state is
204
+ * therefore sent as its opposite.
205
+ */
206
+ export function buildFailoverPayload(clusterId, enabled) {
207
+ return { clusterID: String(clusterId), action: 'handleFailover', currentStatus: enabled ? 0 : 1 };
208
+ }
209
+
210
+ export async function setAutoFailover(clusterId, state) {
211
+ const enabled = parseToggle(state);
212
+ const response = await apiRequest('/api/clusters/DoActionOnCluster', 'POST', buildFailoverPayload(clusterId, enabled));
213
+
214
+ if (response.status === 'KO') throw new Error(response.message || 'Failed to update automatic failover');
215
+ log('success', `Automatic failover ${enabled ? 'enabled' : 'disabled'} on cluster ${clusterId}`);
216
+ return response;
217
+ }
218
+
219
+ export async function resyncCluster(clusterId, projectId, force) {
220
+ if (!force) {
221
+ throw new Error('Re-sync erases all data on the replicas and replaces it with a copy of the primary. Re-run with --force.');
222
+ }
223
+
224
+ const response = await apiRequest('/api/clusters/resyncCluster', 'POST', {
225
+ appid: APPID,
226
+ projectId: requireProject(projectId),
227
+ clusterID: String(clusterId),
228
+ type: 'resync'
229
+ });
230
+
231
+ if (response.status === 'KO') throw new Error(response.message || 'Re-sync failed');
232
+ log('success', `Re-sync requested on cluster ${clusterId}. You will be emailed when it completes.`);
233
+ return response;
234
+ }
235
+
236
+ /**
237
+ * Promoting a replica needs the whole node record plus the cluster's CNAME, so
238
+ * both are resolved here rather than asked of the caller.
239
+ */
240
+ export async function promoteNode(clusterId, vmID, projectId, force) {
241
+ if (!force) {
242
+ throw new Error(`Promoting vmID ${vmID} demotes the current primary. Re-run with --force.`);
243
+ }
244
+
245
+ const pid = requireProject(projectId);
246
+ const info = await getClusterInfo(clusterId, pid);
247
+ const nodes = await listNodesRaw(info, pid);
248
+
249
+ const node = nodes.find(n => String(n.vmID) === String(vmID));
250
+ if (!node) {
251
+ const available = nodes.map(n => n.vmID).join(', ') || 'none';
252
+ throw new Error(`vmID ${vmID} is not an active node of cluster ${clusterId}. Active nodes: ${available}`);
253
+ }
254
+ if (String(node.vmID) === String(info.primaryProviderServerID)) {
255
+ throw new Error(`vmID ${vmID} is already the primary of cluster ${clusterId}`);
256
+ }
257
+
258
+ const response = await apiRequest('/api/clusters/promoteNode', 'POST', {
259
+ appid: APPID,
260
+ projectId: pid,
261
+ serviceId: String(info.primaryProviderServerID),
262
+ clusterId: String(clusterId),
263
+ cname: info.cname,
264
+ currentNode: {
265
+ userID: node.userID,
266
+ id: node.id,
267
+ vmID: node.vmID,
268
+ provider: node.provider,
269
+ datacenter: node.datacenter,
270
+ cname: node.cname,
271
+ clusterID: node.clusterID,
272
+ template: node.template
273
+ }
274
+ });
275
+
276
+ if (response.status === 'KO') throw new Error(response.message || 'Promotion failed');
277
+ log('success', `vmID ${vmID} is being promoted to primary of cluster ${clusterId}`);
278
+ return response;
279
+ }
280
+
281
+ // ── Nodes ──
282
+ //
283
+ // Neither operation has an endpoint of its own. The dashboard adds a node with
284
+ // createServer (serviceType "ClusterNodes" + the primary's serverID), and
285
+ // removes one with deleteServer on the node followed by updateClusterNodes,
286
+ // which only decrements the cluster's node count.
287
+
288
+ const BUSY_STATUSES = ['deploying', 'deleting', 'creating', 'initializing'];
289
+
290
+ /**
291
+ * The status reads "running" while a node is still being turned into a
292
+ * replica; the work in progress is only visible in jsonConfig
293
+ * ({"configuring":{"ids":[...]},"type":"add-node"}), as the dashboard reads it.
294
+ */
295
+ export function clusterConfiguration(info) {
296
+ try {
297
+ const config = typeof info.jsonConfig === 'string' ? JSON.parse(info.jsonConfig) : info.jsonConfig;
298
+ // Once done it stays as {"configuring":{"ids":[]},"type":""}: only a type
299
+ // or pending IDs mean work in progress.
300
+ if (!config || !config.configuring) return null;
301
+ if (config.type) return config.type;
302
+ return Array.isArray(config.configuring.ids) && config.configuring.ids.length > 0 ? 'configuring' : null;
303
+ } catch {
304
+ return null;
305
+ }
306
+ }
307
+
308
+ function isClusterBusy(info) {
309
+ const status = String(info.status || '').toLowerCase();
310
+ return status.includes('configuring') || status.includes('creating') || status === 'being configured' ||
311
+ clusterConfiguration(info) !== null;
312
+ }
313
+
314
+ /** Nodes are named after the cluster: pg-cluster1, pg-cluster2... */
315
+ function nextNodeName(nodes, primary) {
316
+ const base = String(primary.displayName || primary.serverName || '').replace(/\d+$/, '');
317
+ const numbers = nodes.map(n => Number((String(n.displayName || '').match(/(\d+)$/) || [])[1] || 0));
318
+ return `${base}${Math.max(nodes.length, ...numbers) + 1}`;
319
+ }
320
+
321
+ export function addNodeBlocker(info, primary, nodes) {
322
+ if (info.replicationMode === 'master') {
323
+ throw new Error('Nodes cannot be added to a multi-master cluster');
324
+ }
325
+ if (isClusterBusy(info) || primary.status !== 'running') {
326
+ throw new Error(`Cluster ${info.id} is busy (${clusterConfiguration(info) || info.status}); wait until it is running`);
327
+ }
328
+ if (!Number(primary.remoteBackupsActivated)) {
329
+ throw new Error(
330
+ 'A new node is seeded from the primary\'s remote backup, and remote backups are off. ' +
331
+ `Enable them first: elestio backups auto-enable ${primary.vmID}`
332
+ );
333
+ }
334
+ if (nodes.length >= CLUSTER_MAX_NODES) {
335
+ throw new Error(`Cluster ${info.id} already has ${nodes.length} nodes; the maximum is ${CLUSTER_MAX_NODES}`);
336
+ }
337
+ }
338
+
339
+ /**
340
+ * The node must run the primary's version. getServices does not carry it; the
341
+ * primary's details do, as selected_software_tag. Falling back to "latest"
342
+ * could start a replica on another major version, so a missing one is an error.
343
+ */
344
+ export function nodeVersion(primary, override) {
345
+ const version = override || primary.selected_software_tag || primary.version;
346
+ if (!version) throw new Error('Could not read the primary\'s software version; pass it with --version');
347
+ return String(version);
348
+ }
349
+
350
+ export function buildAddNodePayload({ info, primary, nodes, adminEmail, projectId, size, region, provider, version }) {
351
+ return {
352
+ templateID: String(info.templateID),
353
+ serverType: size || primary.serverType,
354
+ datacenter: region || primary.datacenter,
355
+ providerName: provider || primary.provider,
356
+ serverName: nextNodeName(nodes, primary),
357
+ appid: 'CloudVM',
358
+ data: '',
359
+ support: 'level1',
360
+ projectId: projectId === undefined ? undefined : String(projectId),
361
+ version: nodeVersion(primary, version),
362
+ adminEmail,
363
+ deploymentServiceType: 'normal',
364
+ serviceType: 'ClusterNodes',
365
+ isReplica: info.replicationMode === 'replica',
366
+ primaryServerID: String(info.primaryServerID)
367
+ };
368
+ }
369
+
370
+ export async function addClusterNode(clusterId, options = {}) {
371
+ const config = loadConfig();
372
+ const pid = requireProject(options.project);
373
+ const info = await getClusterInfo(clusterId, pid);
374
+ const nodes = await listNodesRaw(info, pid);
375
+ const listed = nodes.find(n => String(n.vmID) === String(info.primaryProviderServerID));
376
+ if (!listed) throw new Error(`Primary of cluster ${clusterId} not found`);
377
+ const primary = { ...listed, ...(await getServiceDetails(listed.vmID, pid)) };
378
+
379
+ addNodeBlocker(info, primary, nodes);
380
+ const payload = buildAddNodePayload({
381
+ info, primary, nodes, projectId: pid,
382
+ adminEmail: options.email || config.email,
383
+ size: options.size, region: options.region, provider: options.provider, version: options.version
384
+ });
385
+
386
+ if (options.dryRun) {
387
+ if (options.json) { outputJson({ dryRun: true, payload }); return payload; }
388
+ console.log(`\n${colors.bold}Add node (--dry-run)${colors.reset}\n`);
389
+ console.log(` Cluster: ${info.id} (${info.templateName || 'N/A'}), ${nodes.length} -> ${nodes.length + 1} nodes`);
390
+ console.log(` New node: ${payload.serverName} (${info.replicationMode === 'replica' ? 'replica' : 'node'})`);
391
+ console.log(` VM: ${payload.providerName} / ${payload.datacenter} / ${payload.serverType}`);
392
+ console.log(` Version: ${payload.version} (same as the primary)`);
393
+ console.log(` ${colors.yellow}Billed as one more VM.${colors.reset}\n`);
394
+ log('info', 'To add it, run the same command without --dry-run');
395
+ return payload;
396
+ }
397
+
398
+ const response = await apiRequest('/api/servers/createServer', 'POST', payload);
399
+ if (!response.providerServerID && !response.action) {
400
+ throw new Error(response.message || 'Failed to add the node');
401
+ }
402
+ log('success', `Node ${payload.serverName} is being added to cluster ${clusterId} (vmID ${response.providerServerID})`);
403
+ log('info', `Follow it with: elestio wait ${response.providerServerID}`);
404
+ return response;
405
+ }
406
+
407
+ export function planRemoveNode(info, nodes, vmID, projectId) {
408
+ const node = nodes.find(n => String(n.vmID) === String(vmID));
409
+ if (!node) {
410
+ throw new Error(`vmID ${vmID} is not a node of cluster ${info.id}. Nodes: ${nodes.map(n => n.vmID).join(', ') || 'none'}`);
411
+ }
412
+ if (String(node.vmID) === String(info.primaryProviderServerID)) {
413
+ throw new Error(`vmID ${vmID} is the primary. Promote a replica first, or delete the whole cluster with: elestio clusters delete ${info.id} --force`);
414
+ }
415
+ if (BUSY_STATUSES.includes(String(node.status).toLowerCase()) || isClusterBusy(info)) {
416
+ throw new Error(`Node ${vmID} or its cluster is busy (${node.status} / ${info.status}); try again once it is running`);
417
+ }
418
+ if (Number(info.templateID) === 183 && nodes.length <= 3) {
419
+ throw new Error('A Vault cluster cannot go below 3 nodes');
420
+ }
421
+ return {
422
+ node,
423
+ update: {
424
+ projectId: String(projectId),
425
+ clusterId: String(info.id),
426
+ templateID: String(node.template ?? info.templateID),
427
+ vmID: String(node.vmID),
428
+ labelName: String(node.labelName || node.displayName || ''),
429
+ appid: 'CloudVM'
430
+ }
431
+ };
432
+ }
433
+
434
+ export async function removeClusterNode(clusterId, vmID, projectId, force) {
435
+ if (!force) {
436
+ throw new Error(`Removing node ${vmID} deletes that VM and its data. Re-run with --force.`);
437
+ }
438
+ const pid = requireProject(projectId);
439
+ const info = await getClusterInfo(clusterId, pid);
440
+ const nodes = await listNodesRaw(info, pid);
441
+ const { update } = planRemoveNode(info, nodes, vmID, pid);
442
+
443
+ await deleteService(vmID, { force: true, project: pid });
444
+ const response = await apiRequest('/api/clusters/updateClusterNodes', 'POST', update);
445
+ if (response.status === 'KO') throw new Error(response.message || 'Node deleted, but the cluster node count was not updated');
446
+ log('success', `Node ${vmID} is being removed from cluster ${clusterId}`);
447
+ return response;
448
+ }
449
+
450
+ // ── Firewall ──
451
+
452
+ const OPEN_TARGETS = ['0.0.0.0/0', '::/0'];
453
+ const IPV4_CIDR = /^(\d{1,3}\.){3}\d{1,3}(\/(3[0-2]|[12]?\d))?$/;
454
+ const IPV6_CIDR = /^[0-9a-fA-F:]+(\/\d{1,3})?$/;
455
+
456
+ export function parseIpList(value) {
457
+ return String(value ?? '').split(',').map(ip => ip.trim()).filter(Boolean);
458
+ }
459
+
460
+ function toCidr(ip) {
461
+ if (IPV4_CIDR.test(ip)) return ip.includes('/') ? ip : `${ip}/32`;
462
+ if (ip.includes(':') && IPV6_CIDR.test(ip)) return ip.includes('/') ? ip : `${ip}/128`;
463
+ throw new Error(`"${ip}" is not an IP address or CIDR range`);
464
+ }
465
+
466
+ function parseRules(raw) {
467
+ if (Array.isArray(raw)) return raw;
468
+ try { return JSON.parse(raw || '[]'); } catch { return []; }
469
+ }
470
+
471
+ /**
472
+ * Returns new rules where each port in `ports` accepts only `ips` (or everyone
473
+ * when `ips` is empty). The other rules are kept as they are.
474
+ */
475
+ export function setPortTargets(rules, ports, ips) {
476
+ const known = rules.map(r => String(r.port));
477
+ for (const port of ports) {
478
+ if (!known.includes(String(port))) {
479
+ throw new Error(`Port ${port} has no rule on this cluster. Ports: ${known.join(', ')}`);
480
+ }
481
+ }
482
+ const targets = ips.length > 0 ? ips.map(toCidr) : OPEN_TARGETS;
483
+ return rules.map(r => ports.map(String).includes(String(r.port)) ? { ...r, targets: [...targets] } : { ...r, targets: [...r.targets] });
484
+ }
485
+
486
+ export async function showClusterFirewall(clusterId, projectId = null, json = false) {
487
+ const info = await getClusterInfo(clusterId, projectId);
488
+ const rules = parseRules(info.firewall_rules);
489
+ if (json) { outputJson(rules); return rules; }
490
+
491
+ console.log(`\n${colors.bold}Firewall of cluster ${clusterId}${colors.reset}\n`);
492
+ console.log(formatTable(rules.map(r => ({
493
+ port: r.port, protocol: r.protocol,
494
+ allowed: r.targets.includes('0.0.0.0/0') ? 'everyone' : r.targets.join(', ')
495
+ })), [
496
+ { key: 'port', label: 'Port' },
497
+ { key: 'protocol', label: 'Protocol' },
498
+ { key: 'allowed', label: 'Allowed from' }
499
+ ]));
500
+ console.log('\n Other nodes of the cluster are always allowed, so replication keeps working.\n');
501
+ return rules;
502
+ }
503
+
504
+ export async function setClusterPortAccess(clusterId, port, ips, projectId = null) {
505
+ const pid = requireProject(projectId);
506
+ const info = await getClusterInfo(clusterId, pid);
507
+ if (isClusterBusy(info)) throw new Error(`Cluster ${clusterId} is busy (${clusterConfiguration(info) || info.status}); try again once it is running`);
508
+ const nodes = await listNodesRaw(info, pid);
509
+ const primary = nodes.find(n => String(n.vmID) === String(info.primaryProviderServerID));
510
+ if (!primary) throw new Error(`Primary of cluster ${clusterId} not found`);
511
+
512
+ const ports = [String(port)];
513
+ const rules = JSON.stringify(setPortTargets(parseRules(info.firewall_rules), ports, ips));
514
+
515
+ for (const node of nodes) {
516
+ log('info', `Updating firewall on ${node.displayName || node.vmID}...`);
517
+ await doAction(node.vmID, 'updateFirewall', {
518
+ rules, clusterTemplate: info.templateID, primaryServerID: primary.id, currentPort: ports
519
+ });
520
+ }
521
+
522
+ const response = await apiRequest('/api/clusters/updateClusterRowFirewall', 'POST', {
523
+ appid: APPID, projectId: pid, clusterID: String(clusterId), firewall_rules: rules
524
+ });
525
+ if (response.status === 'KO') throw new Error(response.message || 'Nodes updated, but the cluster rules were not saved');
526
+
527
+ log('success', ips.length > 0
528
+ ? `Port ${port} of cluster ${clusterId} now only accepts ${ips.join(', ')} (plus the cluster's own nodes)`
529
+ : `Port ${port} of cluster ${clusterId} is open to everyone`);
530
+ }
531
+
532
+ /**
533
+ * There is no cluster delete endpoint: deleting the primary service deletes
534
+ * every node with it. The primary changes after a promote, so it is looked up
535
+ * rather than asked of the caller.
536
+ */
537
+ export function clusterDeletionTarget(info) {
538
+ if (info.isProtected) {
539
+ throw new Error(`Cluster ${info.id} is locked. Unlock it first with: elestio clusters unlock ${info.id}`);
540
+ }
541
+ if (!info.primaryProviderServerID) throw new Error(`Cluster ${info.id} has no known primary node`);
542
+ return String(info.primaryProviderServerID);
543
+ }
544
+
545
+ export async function deleteCluster(clusterId, projectId, force) {
546
+ if (!force) {
547
+ throw new Error(`Deleting cluster ${clusterId} deletes every node and its data. Re-run with --force.`);
548
+ }
549
+
550
+ const pid = requireProject(projectId);
551
+ const info = await getClusterInfo(clusterId, pid);
552
+ const vmID = clusterDeletionTarget(info);
553
+ const nodes = await listNodesRaw(info, pid);
554
+
555
+ await deleteService(vmID, { force: true, project: pid });
556
+ log('success', `Cluster ${clusterId} deletion initiated (${nodes.length || info.nbNodes} nodes)`);
557
+ }
558
+
559
+ // ── Catalog ──
560
+
561
+ export async function listClusterTemplates(json = false) {
562
+ const templates = await getTemplates();
563
+
564
+ // The catalog's own isCluster flag is authoritative and stays current; the
565
+ // constant is only a fallback for when the catalog cannot be reached.
566
+ const clusterable = templates.filter(t => supportsClustering(t));
567
+ const rows = (clusterable.length > 0 ? clusterable : CLUSTER_TEMPLATE_IDS.map(id => ({ id, title: `(unlisted #${id})`, category: 'N/A' })))
568
+ .map(t => ({
569
+ id: t.id,
570
+ title: t.title,
571
+ category: t.category || 'N/A',
572
+ minNodes: minClusterNodes(t.id),
573
+ modes: supportsMultiMaster(t.id) ? 'primary-replica, multi-master' : 'primary-replica'
574
+ }))
575
+ .sort((a, b) => a.title.localeCompare(b.title));
576
+
577
+ if (json) { outputJson(rows); return rows; }
578
+
579
+ console.log(`\n${colors.bold}Templates that support clustering (${rows.length})${colors.reset}\n`);
580
+ console.log(formatTable(rows, [
581
+ { key: 'id', label: 'ID' },
582
+ { key: 'title', label: 'Software' },
583
+ { key: 'category', label: 'Category' },
584
+ { key: 'minNodes', label: 'Min nodes' },
585
+ { key: 'modes', label: 'Modes' }
586
+ ]));
587
+ console.log(`\n Max nodes: ${CLUSTER_MAX_NODES}. Deploy with: ${colors.cyan}elestio deploy <software> --cluster --nodes <n>${colors.reset}\n`);
588
+ return rows;
589
+ }