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.
- package/README.md +242 -4
- package/package.json +9 -2
- package/src/api.js +59 -21
- package/src/cli.js +16 -813
- package/src/commands/access.js +26 -13
- package/src/commands/auth.js +1 -1
- package/src/commands/cicd-template.js +363 -0
- package/src/commands/cicd.js +40 -15
- package/src/commands/clusters.js +337 -0
- package/src/commands/services.js +38 -26
- package/src/config.js +22 -2
- package/src/constants.js +76 -0
- package/src/deployment.js +33 -0
- package/src/payloads/pipeline.js +191 -0
- package/src/payloads/server.js +130 -0
- package/src/registry.js +663 -0
- package/src/router.js +188 -0
- package/src/templates/elestio-config.js +188 -0
- package/src/utils.js +27 -15
|
@@ -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
|
+
}
|
package/src/commands/services.js
CHANGED
|
@@ -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,
|
|
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
|
|
203
|
+
let lastSummary = '';
|
|
190
204
|
|
|
191
205
|
while (Date.now() - start < timeoutMs) {
|
|
192
|
-
const
|
|
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 (
|
|
208
|
+
if (progress.services.length === 0) { await sleep(10000); continue; }
|
|
198
209
|
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
log('info', `Status: ${
|
|
210
|
+
if (progress.summary !== lastSummary) {
|
|
211
|
+
lastSummary = progress.summary;
|
|
212
|
+
log('info', `Status: ${progress.summary}`);
|
|
202
213
|
}
|
|
203
214
|
|
|
204
|
-
if (
|
|
215
|
+
if (progress.done) {
|
|
205
216
|
log('success', 'Deployment complete!');
|
|
206
|
-
console.log('\n' + formatService(svc)
|
|
207
|
-
|
|
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
|
|
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 ──
|
package/src/constants.js
ADDED
|
@@ -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
|
+
}
|