elestio 1.0.2 → 1.1.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,337 @@
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 { listServicesRaw, deleteService } from './services.js';
10
+
11
+ const APPID = 'CloudVM';
12
+
13
+ function requireProject(projectId) {
14
+ const pid = projectId || loadConfig().defaultProject;
15
+ if (!pid) {
16
+ throw new Error('Project ID required. Use --project or set a default with: elestio config --set-default-project <id>');
17
+ }
18
+ return String(pid);
19
+ }
20
+
21
+ // ── Read ──
22
+
23
+ export async function listClustersRaw(projectId = null, includeDeleted = false) {
24
+ const response = await apiRequest('/api/clusters/getClusters', 'POST', {
25
+ appid: APPID,
26
+ projectId: requireProject(projectId),
27
+ isActiveCluster: !includeDeleted
28
+ });
29
+
30
+ if (response.status === 'KO') throw new Error(response.message || 'Failed to list clusters');
31
+ return response.clusters || [];
32
+ }
33
+
34
+ export async function listClusters(projectId = null, json = false) {
35
+ const clusters = await listClustersRaw(projectId);
36
+
37
+ if (json) { outputJson(clusters); return clusters; }
38
+ if (clusters.length === 0) {
39
+ log('info', 'No clusters in this project. Create one with: elestio deploy <template> --cluster');
40
+ return [];
41
+ }
42
+
43
+ const rows = clusters.map(c => ({
44
+ id: c.id,
45
+ templateName: c.templateName || 'N/A',
46
+ mode: c.replicationMode || 'N/A',
47
+ nodes: `${c.totalNodes ?? c.nbNodes ?? '?'}`,
48
+ status: c.status || 'N/A',
49
+ cname: c.cname || 'pending',
50
+ cost: c.totalCost ? formatPrice(c.totalCost) : 'N/A'
51
+ }));
52
+
53
+ console.log(`\n${colors.bold}Clusters (${clusters.length})${colors.reset}\n`);
54
+ console.log(formatTable(rows, [
55
+ { key: 'id', label: 'ID' },
56
+ { key: 'templateName', label: 'Software' },
57
+ { key: 'mode', label: 'Mode' },
58
+ { key: 'nodes', label: 'Nodes' },
59
+ { key: 'status', label: 'Status' },
60
+ { key: 'cname', label: 'CNAME' },
61
+ { key: 'cost', label: 'Cost' }
62
+ ]));
63
+ console.log('');
64
+ return clusters;
65
+ }
66
+
67
+ /**
68
+ * The API keys cluster lookups on the primary node's provider server ID, not
69
+ * on the cluster ID, so resolve one from the other.
70
+ */
71
+ export async function getClusterInfo(clusterId, projectId = null) {
72
+ const clusters = await listClustersRaw(projectId);
73
+ const cluster = clusters.find(c => String(c.id) === String(clusterId));
74
+ if (!cluster) throw new Error(`Cluster ${clusterId} not found in this project`);
75
+
76
+ const response = await apiRequest('/api/clusters/getClusterInfos', 'POST', {
77
+ appid: APPID,
78
+ projectId: requireProject(projectId),
79
+ serviceId: String(cluster.primaryProviderServerID)
80
+ });
81
+
82
+ if (response.status === 'KO') throw new Error(response.message || 'Failed to get cluster info');
83
+ return { ...cluster, ...(response.clusters || {}) };
84
+ }
85
+
86
+ export async function showCluster(clusterId, projectId = null, json = false) {
87
+ const info = await getClusterInfo(clusterId, projectId);
88
+ const nodes = await listNodesRaw(info, projectId);
89
+
90
+ if (json) { outputJson({ ...info, nodes }); return info; }
91
+
92
+ console.log(`\n${colors.bold}Cluster ${info.id} - ${info.templateName || 'N/A'}${colors.reset}\n`);
93
+ console.log(` Status: ${info.status || 'N/A'} (${info.deploymentStatus || 'N/A'})`);
94
+ console.log(` Mode: ${info.replicationMode || 'N/A'}`);
95
+ console.log(` Nodes: ${info.totalNodes ?? info.nbNodes ?? nodes.length}`);
96
+ console.log(` CNAME: ${info.cname || 'pending'}`);
97
+ console.log(` Primary: vmID ${info.primaryProviderServerID || 'N/A'}`);
98
+ console.log(` Provider: ${info.provider || 'N/A'} / ${info.datacenter || 'N/A'}`);
99
+ console.log(` Firewall: ${info.isFirewallActivated ? 'enabled' : 'disabled'}`);
100
+ console.log(` Protected: ${info.isProtected ? 'yes' : 'no'}`);
101
+ console.log(` Failover: ${info.isFailoverEnabled ? 'automatic' : 'manual only'}`);
102
+ if (info.totalCost) console.log(` Cost: ${formatPrice(info.totalCost)}`);
103
+
104
+ if (nodes.length > 0) {
105
+ console.log(`\n${colors.bold}Nodes${colors.reset}\n`);
106
+ console.log(formatTable(nodeRows(nodes, info), NODE_COLUMNS));
107
+ }
108
+ console.log('');
109
+ return info;
110
+ }
111
+
112
+ /**
113
+ * Replicas carry the primary's serverID in their clusterID field, not the
114
+ * cluster's own ID. getActivesNodesByClusterID works the same way and only
115
+ * returns providerServerIDs, while promote needs the full node record, so the
116
+ * nodes are picked out of the project's services instead.
117
+ */
118
+ export function selectClusterNodes(services, cluster) {
119
+ const primary = services.find(s =>
120
+ (cluster.primaryServerID && String(s.id) === String(cluster.primaryServerID)) ||
121
+ String(s.vmID) === String(cluster.primaryProviderServerID)
122
+ );
123
+ if (!primary) return [];
124
+
125
+ const replicas = services.filter(s => s !== primary && String(s.clusterID) === String(primary.id));
126
+ return [primary, ...replicas];
127
+ }
128
+
129
+ export async function listNodesRaw(cluster, projectId = null) {
130
+ const services = await listServicesRaw(requireProject(projectId));
131
+ return selectClusterNodes(services, cluster);
132
+ }
133
+
134
+ const NODE_COLUMNS = [
135
+ { key: 'role', label: 'Role' },
136
+ { key: 'vmID', label: 'vmID' },
137
+ { key: 'displayName', label: 'Name' },
138
+ { key: 'status', label: 'Status' },
139
+ { key: 'ipv4', label: 'IP' },
140
+ { key: 'datacenter', label: 'Region' }
141
+ ];
142
+
143
+ function nodeRows(nodes, info) {
144
+ const primaryVmId = info ? String(info.primaryProviderServerID) : null;
145
+ return nodes.map(n => ({
146
+ role: String(n.vmID) === primaryVmId ? 'primary' : 'replica',
147
+ vmID: n.vmID ?? 'N/A',
148
+ displayName: n.displayName || n.serverName || 'N/A',
149
+ status: n.status || 'N/A',
150
+ ipv4: n.ipv4 || 'pending',
151
+ datacenter: n.datacenter || 'N/A'
152
+ }));
153
+ }
154
+
155
+ export async function listNodes(clusterId, projectId = null, json = false) {
156
+ const info = await getClusterInfo(clusterId, projectId);
157
+ const nodes = await listNodesRaw(info, projectId);
158
+
159
+ if (json) { outputJson(nodes); return nodes; }
160
+ if (nodes.length === 0) { log('info', `No active nodes on cluster ${clusterId}`); return []; }
161
+
162
+ console.log(`\n${colors.bold}Nodes of cluster ${clusterId}${colors.reset}\n`);
163
+ console.log(formatTable(nodeRows(nodes, info), NODE_COLUMNS));
164
+ console.log('');
165
+ return nodes;
166
+ }
167
+
168
+ // ── Actions ──
169
+
170
+ async function doActionOnCluster(clusterId, action) {
171
+ const response = await apiRequest('/api/clusters/DoActionOnCluster', 'POST', {
172
+ clusterID: String(clusterId),
173
+ action
174
+ });
175
+
176
+ if (response.status === 'KO') throw new Error(response.message || `Action "${action}" failed`);
177
+ return response;
178
+ }
179
+
180
+ export async function lockCluster(clusterId) {
181
+ const result = await doActionOnCluster(clusterId, 'lock');
182
+ log('success', `Cluster ${clusterId} locked (termination protection enabled)`);
183
+ return result;
184
+ }
185
+
186
+ export async function unlockCluster(clusterId) {
187
+ const result = await doActionOnCluster(clusterId, 'unlock');
188
+ log('success', `Cluster ${clusterId} unlocked`);
189
+ return result;
190
+ }
191
+
192
+ const TOGGLES = { on: true, enable: true, true: true, off: false, disable: false, false: false };
193
+
194
+ export function parseToggle(value) {
195
+ const key = String(value ?? '').toLowerCase();
196
+ if (!(key in TOGGLES)) throw new Error('Expected on|off');
197
+ return TOGGLES[key];
198
+ }
199
+
200
+ /**
201
+ * handleFailover does not fail over: it is the dashboard's automatic-failover
202
+ * switch, and it sets isFailoverEnabled to !currentStatus. The wanted state is
203
+ * therefore sent as its opposite.
204
+ */
205
+ export function buildFailoverPayload(clusterId, enabled) {
206
+ return { clusterID: String(clusterId), action: 'handleFailover', currentStatus: enabled ? 0 : 1 };
207
+ }
208
+
209
+ export async function setAutoFailover(clusterId, state) {
210
+ const enabled = parseToggle(state);
211
+ const response = await apiRequest('/api/clusters/DoActionOnCluster', 'POST', buildFailoverPayload(clusterId, enabled));
212
+
213
+ if (response.status === 'KO') throw new Error(response.message || 'Failed to update automatic failover');
214
+ log('success', `Automatic failover ${enabled ? 'enabled' : 'disabled'} on cluster ${clusterId}`);
215
+ return response;
216
+ }
217
+
218
+ export async function resyncCluster(clusterId, projectId, force) {
219
+ if (!force) {
220
+ throw new Error('Re-sync erases all data on the replicas and replaces it with a copy of the primary. Re-run with --force.');
221
+ }
222
+
223
+ const response = await apiRequest('/api/clusters/resyncCluster', 'POST', {
224
+ appid: APPID,
225
+ projectId: requireProject(projectId),
226
+ clusterID: String(clusterId),
227
+ type: 'resync'
228
+ });
229
+
230
+ if (response.status === 'KO') throw new Error(response.message || 'Re-sync failed');
231
+ log('success', `Re-sync requested on cluster ${clusterId}. You will be emailed when it completes.`);
232
+ return response;
233
+ }
234
+
235
+ /**
236
+ * Promoting a replica needs the whole node record plus the cluster's CNAME, so
237
+ * both are resolved here rather than asked of the caller.
238
+ */
239
+ export async function promoteNode(clusterId, vmID, projectId, force) {
240
+ if (!force) {
241
+ throw new Error(`Promoting vmID ${vmID} demotes the current primary. Re-run with --force.`);
242
+ }
243
+
244
+ const pid = requireProject(projectId);
245
+ const info = await getClusterInfo(clusterId, pid);
246
+ const nodes = await listNodesRaw(info, pid);
247
+
248
+ const node = nodes.find(n => String(n.vmID) === String(vmID));
249
+ if (!node) {
250
+ const available = nodes.map(n => n.vmID).join(', ') || 'none';
251
+ throw new Error(`vmID ${vmID} is not an active node of cluster ${clusterId}. Active nodes: ${available}`);
252
+ }
253
+ if (String(node.vmID) === String(info.primaryProviderServerID)) {
254
+ throw new Error(`vmID ${vmID} is already the primary of cluster ${clusterId}`);
255
+ }
256
+
257
+ const response = await apiRequest('/api/clusters/promoteNode', 'POST', {
258
+ appid: APPID,
259
+ projectId: pid,
260
+ serviceId: String(info.primaryProviderServerID),
261
+ clusterId: String(clusterId),
262
+ cname: info.cname,
263
+ currentNode: {
264
+ userID: node.userID,
265
+ id: node.id,
266
+ vmID: node.vmID,
267
+ provider: node.provider,
268
+ datacenter: node.datacenter,
269
+ cname: node.cname,
270
+ clusterID: node.clusterID,
271
+ template: node.template
272
+ }
273
+ });
274
+
275
+ if (response.status === 'KO') throw new Error(response.message || 'Promotion failed');
276
+ log('success', `vmID ${vmID} is being promoted to primary of cluster ${clusterId}`);
277
+ return response;
278
+ }
279
+
280
+ /**
281
+ * There is no cluster delete endpoint: deleting the primary service deletes
282
+ * every node with it. The primary changes after a promote, so it is looked up
283
+ * rather than asked of the caller.
284
+ */
285
+ export function clusterDeletionTarget(info) {
286
+ if (info.isProtected) {
287
+ throw new Error(`Cluster ${info.id} is locked. Unlock it first with: elestio clusters unlock ${info.id}`);
288
+ }
289
+ if (!info.primaryProviderServerID) throw new Error(`Cluster ${info.id} has no known primary node`);
290
+ return String(info.primaryProviderServerID);
291
+ }
292
+
293
+ export async function deleteCluster(clusterId, projectId, force) {
294
+ if (!force) {
295
+ throw new Error(`Deleting cluster ${clusterId} deletes every node and its data. Re-run with --force.`);
296
+ }
297
+
298
+ const pid = requireProject(projectId);
299
+ const info = await getClusterInfo(clusterId, pid);
300
+ const vmID = clusterDeletionTarget(info);
301
+ const nodes = await listNodesRaw(info, pid);
302
+
303
+ await deleteService(vmID, { force: true, project: pid });
304
+ log('success', `Cluster ${clusterId} deletion initiated (${nodes.length || info.nbNodes} nodes)`);
305
+ }
306
+
307
+ // ── Catalog ──
308
+
309
+ export async function listClusterTemplates(json = false) {
310
+ const templates = await getTemplates();
311
+
312
+ // The catalog's own isCluster flag is authoritative and stays current; the
313
+ // constant is only a fallback for when the catalog cannot be reached.
314
+ const clusterable = templates.filter(t => supportsClustering(t));
315
+ const rows = (clusterable.length > 0 ? clusterable : CLUSTER_TEMPLATE_IDS.map(id => ({ id, title: `(unlisted #${id})`, category: 'N/A' })))
316
+ .map(t => ({
317
+ id: t.id,
318
+ title: t.title,
319
+ category: t.category || 'N/A',
320
+ minNodes: minClusterNodes(t.id),
321
+ modes: supportsMultiMaster(t.id) ? 'primary-replica, multi-master' : 'primary-replica'
322
+ }))
323
+ .sort((a, b) => a.title.localeCompare(b.title));
324
+
325
+ if (json) { outputJson(rows); return rows; }
326
+
327
+ console.log(`\n${colors.bold}Templates that support clustering (${rows.length})${colors.reset}\n`);
328
+ console.log(formatTable(rows, [
329
+ { key: 'id', label: 'ID' },
330
+ { key: 'title', label: 'Software' },
331
+ { key: 'category', label: 'Category' },
332
+ { key: 'minNodes', label: 'Min nodes' },
333
+ { key: 'modes', label: 'Modes' }
334
+ ]));
335
+ console.log(`\n Max nodes: ${CLUSTER_MAX_NODES}. Deploy with: ${colors.cyan}elestio deploy <software> --cluster --nodes <n>${colors.reset}\n`);
336
+ return rows;
337
+ }
@@ -1,7 +1,9 @@
1
1
  import { apiRequest } from '../api.js';
2
2
  import { loadConfig } from '../config.js';
3
3
  import { findTemplate } from './templates.js';
4
- import { formatTable, formatService, formatPrice, colors, log, sleep, validateServerName, outputJson } from '../utils.js';
4
+ import { formatTable, formatService, colors, log, sleep, validateServerName, outputJson } from '../utils.js';
5
+ import { buildCreateServerPayload, validateClusterOptions, describeCluster } from '../payloads/server.js';
6
+ import { parseVmIDs, deploymentProgress } from '../deployment.js';
5
7
 
6
8
  async function listProjectsRaw() {
7
9
  const response = await apiRequest('/api/projects/getList');
@@ -127,14 +129,30 @@ export async function deployService(templateNameOrId, options = {}) {
127
129
  const isCicd = template.title?.toLowerCase().includes('ci-cd') || templateNameOrId?.toLowerCase() === 'cicd';
128
130
  const serviceType = isCicd ? 'CICD' : 'Service';
129
131
 
132
+ // Validated before anything is created: a rejected cluster still bills the
133
+ // VMs the provider has already started.
134
+ const cluster = options.cluster
135
+ ? validateClusterOptions({ mode: options.clusterMode, nodes: options.nodes }, template)
136
+ : null;
137
+
138
+ if (cluster && isCicd) throw new Error('A CI/CD target cannot be deployed as a cluster');
139
+
140
+ const payload = buildCreateServerPayload({
141
+ template, projectId, serverName, serverType, datacenter, provider,
142
+ support, adminEmail, version, serviceType, cluster,
143
+ pipelineName: options.pipelineName, cicdMode: options.cicdMode
144
+ });
145
+
130
146
  if (options.dryRun) {
131
147
  const preview = {
132
148
  template: template.title, templateId: template.id, version,
133
- projectId, serverName, provider, datacenter, serverType, support, adminEmail, serviceType
149
+ projectId, serverName, provider, datacenter, serverType, support, adminEmail, serviceType,
150
+ cluster: cluster ? { mode: cluster.mode, nodes: cluster.nodes, isReplica: cluster.isReplica } : null,
151
+ topology: describeCluster(cluster)
134
152
  };
135
153
 
136
154
  if (options.json) {
137
- outputJson({ dryRun: true, ...preview });
155
+ outputJson({ dryRun: true, ...preview, payload });
138
156
  return preview;
139
157
  }
140
158
 
@@ -148,6 +166,8 @@ export async function deployService(templateNameOrId, options = {}) {
148
166
  console.log(` Size: ${serverType}`);
149
167
  console.log(` Support: ${support}`);
150
168
  console.log(` Admin: ${adminEmail}`);
169
+ console.log(` Topology: ${describeCluster(cluster)}`);
170
+ if (cluster) console.log(` ${colors.yellow}Billing is per VM: ${cluster.nodes} VMs will be created.${colors.reset}`);
151
171
  console.log('');
152
172
  log('info', 'To deploy, run the same command without --dry-run');
153
173
  return preview;
@@ -156,17 +176,7 @@ export async function deployService(templateNameOrId, options = {}) {
156
176
  log('info', `Deploying ${template.title} (ID: ${template.id})`);
157
177
  log('info', ` Project: ${projectId} | Name: ${serverName}`);
158
178
  log('info', ` Provider: ${provider} | Size: ${serverType} @ ${datacenter}`);
159
-
160
- const payload = {
161
- templateID: String(template.id), serverType, datacenter,
162
- providerName: provider, serverName, appid: 'Cloudxx',
163
- data: 'data', support, projectId: String(projectId),
164
- version, adminEmail, deploymentServiceType: 'normal', serviceType
165
- };
166
-
167
- if (isCicd) {
168
- payload.cicdPayload = { pipelineName: options.pipelineName || serverName };
169
- }
179
+ if (cluster) log('info', ` Cluster: ${describeCluster(cluster)}`);
170
180
 
171
181
  const response = await apiRequest('/api/servers/createServer', 'POST', payload);
172
182
 
@@ -175,6 +185,7 @@ export async function deployService(templateNameOrId, options = {}) {
175
185
  }
176
186
 
177
187
  log('success', `Deployment started! Provider Server ID: ${response.providerServerID}`);
188
+ if (cluster) log('info', 'Replicas are configured after the primary is up; follow progress with: elestio clusters list');
178
189
 
179
190
  if (options.wait !== false) {
180
191
  log('info', 'Waiting for deployment to complete...');
@@ -185,26 +196,27 @@ export async function deployService(templateNameOrId, options = {}) {
185
196
  }
186
197
 
187
198
  export async function waitForDeployment(vmID, projectId, timeoutMs = 600000) {
199
+ const ids = parseVmIDs(vmID);
200
+ if (ids.length === 0) throw new Error('No vmID to wait for');
201
+
188
202
  const start = Date.now();
189
- let lastStatus = '';
203
+ let lastSummary = '';
190
204
 
191
205
  while (Date.now() - start < timeoutMs) {
192
- const services = await listServicesRaw(projectId);
193
- const svc = services.find(s =>
194
- String(s.vmID) === String(vmID) || String(s.providerServerID) === String(vmID)
195
- );
206
+ const progress = deploymentProgress(await listServicesRaw(projectId), ids);
196
207
 
197
- if (!svc) { await sleep(10000); continue; }
208
+ if (progress.services.length === 0) { await sleep(10000); continue; }
198
209
 
199
- if (svc.deploymentStatus !== lastStatus) {
200
- lastStatus = svc.deploymentStatus;
201
- log('info', `Status: ${svc.deploymentStatus}`);
210
+ if (progress.summary !== lastSummary) {
211
+ lastSummary = progress.summary;
212
+ log('info', `Status: ${progress.summary}`);
202
213
  }
203
214
 
204
- if (svc.deploymentStatus === 'Deployed' && svc.status === 'running') {
215
+ if (progress.done) {
205
216
  log('success', 'Deployment complete!');
206
- console.log('\n' + formatService(svc) + '\n');
207
- return svc;
217
+ for (const svc of progress.services) console.log('\n' + formatService(svc));
218
+ console.log('');
219
+ return ids.length === 1 ? progress.services[0] : progress.services;
208
220
  }
209
221
 
210
222
  await sleep(15000);
package/src/config.js CHANGED
@@ -21,6 +21,15 @@ const DEFAULT_CONFIG = {
21
21
  function ensureDir() {
22
22
  if (!fs.existsSync(ELESTIO_DIR)) {
23
23
  fs.mkdirSync(ELESTIO_DIR, { mode: 0o700, recursive: true });
24
+ return;
25
+ }
26
+
27
+ // A directory created by an older version (or restored from a backup) may be
28
+ // group/world readable; both files below hold credentials.
29
+ try {
30
+ fs.chmodSync(ELESTIO_DIR, 0o700);
31
+ } catch {
32
+ // ignore
24
33
  }
25
34
  }
26
35
 
@@ -48,6 +57,11 @@ export function saveCredentials(email, apiToken) {
48
57
  JSON.stringify({ email, apiToken }, null, 2),
49
58
  { mode: 0o600 }
50
59
  );
60
+ try {
61
+ fs.chmodSync(CREDENTIALS_PATH, 0o600);
62
+ } catch {
63
+ // ignore
64
+ }
51
65
  }
52
66
 
53
67
  // ── Config (jwt, defaults, defaultProject) ──
@@ -74,9 +88,15 @@ export function loadConfig() {
74
88
 
75
89
  export function saveConfig(config) {
76
90
  ensureDir();
77
- // Never persist credentials in config.json
91
+ // Never persist the API token in config.json -- but the JWT it does hold is a
92
+ // bearer credential, so the file is owner-only like the credentials file.
78
93
  const { email, apiToken, ...safeConfig } = config;
79
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(safeConfig, null, 2));
94
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(safeConfig, null, 2), { mode: 0o600 });
95
+ try {
96
+ fs.chmodSync(CONFIG_PATH, 0o600);
97
+ } catch {
98
+ // ignore
99
+ }
80
100
  }
81
101
 
82
102
  // ── Exports ──
@@ -0,0 +1,76 @@
1
+ // ── Clustering ──
2
+ //
3
+ // The catalog marks clusterable software with isCluster=1, and that is what we
4
+ // trust: it tracks new templates on its own. The list below is only the offline
5
+ // fallback for when a template object is unavailable -- it mirrors the
6
+ // dashboard's hardcoded list and is therefore always at risk of lagging.
7
+
8
+ export const CLUSTER_TEMPLATE_IDS = [
9
+ 3, // Redis
10
+ 11, // PostgreSQL
11
+ 12, // MySQL
12
+ 45, // TimescaleDB
13
+ 54, // N8N
14
+ 66, // Vaultwarden
15
+ 75, // KeyDB
16
+ 93, // ClickHouse
17
+ 108, // Keycloak
18
+ 183, // Vault
19
+ 195, // OpenSearch
20
+ 212, // RabbitMQ
21
+ 309, // Nebula
22
+ 436, // Valkey
23
+ 467, // rke2
24
+ 488, // Nats
25
+ 490, // pgvector
26
+ 497 // pgDuckDB
27
+ ];
28
+
29
+ // Quorum-based software needs an odd number of nodes to elect a leader.
30
+ export const CLUSTER_MIN_THREE_NODES = [93, 183, 195, 212, 467, 488];
31
+
32
+ // Only MySQL offers writes on more than one node.
33
+ export const CLUSTER_MULTI_MASTER_TEMPLATE_IDS = [12];
34
+
35
+ export const CLUSTER_MAX_NODES = 15;
36
+
37
+ export const CLUSTER_MODES = ['primary-replica', 'multi-master'];
38
+
39
+ /**
40
+ * Minimum node count (primary included) accepted for a template.
41
+ */
42
+ export function minClusterNodes(templateId) {
43
+ return CLUSTER_MIN_THREE_NODES.includes(Number(templateId)) ? 3 : 2;
44
+ }
45
+
46
+ /**
47
+ * @param {object|number} template a catalog template, or just its ID
48
+ */
49
+ export function supportsClustering(template) {
50
+ if (template && typeof template === 'object' && template.isCluster !== undefined) {
51
+ return Number(template.isCluster) === 1;
52
+ }
53
+ const id = Number(template && typeof template === 'object' ? template.id : template);
54
+ return CLUSTER_TEMPLATE_IDS.includes(id);
55
+ }
56
+
57
+ export function supportsMultiMaster(templateId) {
58
+ return CLUSTER_MULTI_MASTER_TEMPLATE_IDS.includes(Number(templateId));
59
+ }
60
+
61
+ // ── CI/CD ──
62
+
63
+ export const CICD_TARGET_TEMPLATE_ID = 234;
64
+
65
+ // Accepted by createServer's cicdPayload.CICDMode and by createCiCdExistServer.
66
+ export const CICD_MODES = ['GITHUB', 'GITLAB', 'GITLAB_SELF_HOSTED', 'DockerCompose'];
67
+
68
+ // Where `elestio cicd deploy-template` sources catalog software from.
69
+ export const TEMPLATE_REPO_OWNER = 'elestio-examples';
70
+ export const TEMPLATE_REPO_HOST = 'https://github.com';
71
+
72
+ export const RUNTIME_PRESETS = {
73
+ static: { runtime: 'staticSPA', buildDir: '/dist', framework: 'Vite.js', buildCmd: 'npm run build', runCmd: '', installCmd: 'npm install', version: '20', containerPort: '3000' },
74
+ node: { runtime: 'NodeJs', buildDir: '/', framework: 'No Framework', buildCmd: 'npm run build', runCmd: 'npm start', installCmd: 'npm install', version: '20', containerPort: '3000' },
75
+ docker: { runtime: 'NodeJs', buildDir: '/', framework: 'NoFramework', buildCmd: '', runCmd: '', installCmd: '', version: '20', containerPort: '3000' }
76
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Deployment tracking, kept free of I/O so it can be tested.
3
+ *
4
+ * createServer answers a single service with one providerServerID, but a
5
+ * cluster with every node in one comma-separated string ("947349,947350").
6
+ * Comparing that string to a service's vmID never matches, so waiting on a
7
+ * cluster used to run until the timeout.
8
+ */
9
+
10
+ export function parseVmIDs(value) {
11
+ const parts = Array.isArray(value) ? value : String(value ?? '').split(',');
12
+ return parts.map(id => String(id).trim()).filter(Boolean);
13
+ }
14
+
15
+ function matches(service, id) {
16
+ return String(service.vmID) === id || String(service.providerServerID) === id;
17
+ }
18
+
19
+ function isReady(service) {
20
+ return service.deploymentStatus === 'Deployed' && service.status === 'running';
21
+ }
22
+
23
+ export function deploymentProgress(services, ids) {
24
+ const found = ids.map(id => ({ id, service: services.find(s => matches(s, id)) }));
25
+ const present = found.filter(f => f.service);
26
+
27
+ return {
28
+ services: present.map(f => f.service),
29
+ missing: found.filter(f => !f.service).map(f => f.id),
30
+ done: ids.length > 0 && present.length === ids.length && present.every(f => isReady(f.service)),
31
+ summary: present.map(f => `${f.id}: ${f.service.deploymentStatus}`).join(', ')
32
+ };
33
+ }