elestio 1.1.0 → 1.2.3
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 +48 -11
- package/package.json +3 -3
- package/src/api.js +28 -0
- package/src/commands/access.js +81 -2
- package/src/commands/cicd-template.js +48 -23
- package/src/commands/cicd.js +68 -6
- package/src/commands/clusters.js +253 -1
- package/src/registry.js +53 -1
- package/src/templates/elestio-config.js +14 -2
package/README.md
CHANGED
|
@@ -145,6 +145,11 @@ already started billing its VMs.
|
|
|
145
145
|
| `elestio clusters resync <clusterID> --force` | Re-sync replicas from the primary |
|
|
146
146
|
| `elestio clusters lock <clusterID>` | Enable termination protection |
|
|
147
147
|
| `elestio clusters unlock <clusterID>` | Disable termination protection |
|
|
148
|
+
| `elestio clusters add-node <clusterID> [--dry-run]` | Add a node, copying the primary's provider, region, size and version |
|
|
149
|
+
| `elestio clusters remove-node <clusterID> <vmID> --force` | Remove a replica node and its VM |
|
|
150
|
+
| `elestio clusters firewall <clusterID>` | Show which IPs each port accepts |
|
|
151
|
+
| `elestio clusters firewall-restrict <clusterID> --port P --ips ip1,ip2` | Only accept a port from these IPs, on every node |
|
|
152
|
+
| `elestio clusters firewall-open <clusterID> --port P` | Open a port to everyone again |
|
|
148
153
|
| `elestio clusters delete <clusterID> --force` | Delete the cluster and all its nodes |
|
|
149
154
|
|
|
150
155
|
`promote`, `resync` and `delete` require `--force`: promotion demotes the
|
|
@@ -152,6 +157,20 @@ current primary, re-sync **erases all data on the replicas** and replaces it
|
|
|
152
157
|
with a copy of the primary, and delete removes every node. A locked cluster
|
|
153
158
|
must be unlocked before it can be deleted.
|
|
154
159
|
|
|
160
|
+
**Nodes.** `add-node` copies the primary: same provider, region, size and
|
|
161
|
+
software version (`--size`, `--region`, `--provider` or `--version` to change
|
|
162
|
+
them). It is billed as one more VM, so dry-run it first. It needs remote
|
|
163
|
+
backups on the primary, which seed the new node (`elestio backups auto-enable
|
|
164
|
+
<vmID>`), and is not available on multi-master clusters. After the VM is
|
|
165
|
+
deployed, Elestio still spends a few minutes turning it into a replica: the
|
|
166
|
+
cluster reads `running` meanwhile, and the CLI refuses other node or firewall
|
|
167
|
+
changes until that is done. `remove-node` only removes replicas; to remove the
|
|
168
|
+
primary, `promote` a replica first or delete the whole cluster.
|
|
169
|
+
|
|
170
|
+
**Firewall.** `firewall-restrict` applies to every node, and the cluster's
|
|
171
|
+
own nodes stay allowed so replication keeps working. Use it rather than
|
|
172
|
+
`elestio firewall` on a single node.
|
|
173
|
+
|
|
155
174
|
`failover` does not switch the primary itself. It turns on or off the automatic
|
|
156
175
|
failover that promotes a replica when the primary goes down; use `promote` to
|
|
157
176
|
switch by hand. Its state shows in `clusters info`.
|
|
@@ -267,6 +286,8 @@ S3 options: `--key`, `--secret`, `--bucket`, `--endpoint`, `--prefix`
|
|
|
267
286
|
| `elestio ssh <vmID> --direct` | Get direct SSH connection info |
|
|
268
287
|
| `elestio vscode <vmID>` | Get VSCode web URL |
|
|
269
288
|
| `elestio files <vmID>` | Get file explorer URL |
|
|
289
|
+
| `elestio logs <vmID> [--mode app\|install]` | Open a live log view (temporary URL): the app's container logs, or the install log |
|
|
290
|
+
| `elestio audits <vmID> [--days N]` | Audit trail: who did what (default: last 30 days) |
|
|
270
291
|
|
|
271
292
|
### Volumes
|
|
272
293
|
|
|
@@ -334,16 +355,30 @@ domain.
|
|
|
334
355
|
| Repo files the compose mounts | Unavailable | Available |
|
|
335
356
|
| Works for | Templates that need no files from the repo | Every template |
|
|
336
357
|
|
|
337
|
-
The compose route has no checkout, so a template
|
|
338
|
-
bind-mounts a file
|
|
339
|
-
as an empty directory and the container fails to start. The
|
|
340
|
-
|
|
341
|
-
|
|
358
|
+
The compose route has no checkout and runs no install script, so a template
|
|
359
|
+
whose `docker-compose.yml` bind-mounts a file cannot work: Docker creates the
|
|
360
|
+
missing source as an empty directory and the container fails to start. The file
|
|
361
|
+
may ship in the template repo (n8n's `./n8n-task-runners.json`) or be generated
|
|
362
|
+
by an install script (Kafka writes its `./jaas/*.conf` in `preInstall`) - either
|
|
363
|
+
way it is absent here. The CLI detects this from the compose file and refuses
|
|
364
|
+
upfront, naming the files, rather than letting the build fail a minute later
|
|
365
|
+
with a `runc` error. `--force` overrides, and will most likely still fail.
|
|
366
|
+
|
|
367
|
+
Quoted mounts (`- "./.env:/app/.env"`) and the long `type: bind` syntax count
|
|
368
|
+
too; missing them is how Chromadb once deployed into a broken container.
|
|
342
369
|
|
|
343
370
|
This affects more templates than you would expect - n8n
|
|
344
|
-
(`./n8n-task-runners.json`),
|
|
345
|
-
WordPress (`./php.ini`) are all in
|
|
346
|
-
Metabase deploy cleanly on the compose
|
|
371
|
+
(`./n8n-task-runners.json`), Chromadb (`./.env`, `./overrides/system.js`),
|
|
372
|
+
Rybbit (four files under `./configs/`) and WordPress (`./php.ini`) are all in
|
|
373
|
+
this category. Vaultwarden, Redis and Metabase deploy cleanly on the compose
|
|
374
|
+
route.
|
|
375
|
+
|
|
376
|
+
**Every template also declares install scripts** (`preInstallCommand`,
|
|
377
|
+
`postInstallCommand`) that the compose route cannot run, and what they do
|
|
378
|
+
cannot be read from outside Elestio. Most software does not need them -
|
|
379
|
+
vaultwarden, redis and metabase were verified to deploy and run - so the CLI
|
|
380
|
+
warns and continues rather than refusing. If the software comes up without its
|
|
381
|
+
admin account or initial data, deploy it as a managed service instead.
|
|
347
382
|
|
|
348
383
|
> **The git route is currently unavailable.** It needs
|
|
349
384
|
> `POST /api/cicd/createRepoByTemplate`, which the Elestio API returns 404 for:
|
|
@@ -362,7 +397,8 @@ Metabase deploy cleanly on the compose route.
|
|
|
362
397
|
| `elestio cicd pipeline-delete <vmID> <pipelineID> --force` | Delete pipeline |
|
|
363
398
|
| `elestio cicd pipeline-resync <vmID> <pipelineID>` | Re-sync pipeline |
|
|
364
399
|
| `elestio cicd pipeline-logs <vmID> <pipelineID>` | View pipeline logs |
|
|
365
|
-
| `elestio cicd pipeline-history <vmID> <pipelineID>` | Build history |
|
|
400
|
+
| `elestio cicd pipeline-history <vmID> <pipelineID>` | Build history, with each build's log file |
|
|
401
|
+
| `elestio cicd pipeline-log <vmID> --pipeline <id> [--file <logID>]` | Open a build log (latest, or one from the history) |
|
|
366
402
|
| `elestio cicd create --auto --target <vmID> --name X --repo owner/repo` | Pipeline from your own repo |
|
|
367
403
|
| `elestio cicd create <config.json>` | Create from config file |
|
|
368
404
|
| `elestio cicd template [mode]` | Generate config template |
|
|
@@ -386,7 +422,7 @@ Metabase deploy cleanly on the compose route.
|
|
|
386
422
|
| `--build-cmd`, `--run-cmd`, `--install-cmd`, `--build-dir` | Override the values from `elestio.yml` (git route only) |
|
|
387
423
|
| `--variables <KEY=VALUE...>` | Override the environment variables, newline-separated (git route only) |
|
|
388
424
|
| `--force` | Deploy on the compose route even when the compose mounts a repo file |
|
|
389
|
-
| `--dry-run` | Print the plan, create nothing |
|
|
425
|
+
| `--dry-run` | Print the plan, create nothing - including on the git route, which never creates the repository |
|
|
390
426
|
|
|
391
427
|
#### Pipelines from your own repository
|
|
392
428
|
|
|
@@ -467,7 +503,8 @@ you to supply all of that yourself.
|
|
|
467
503
|
|
|
468
504
|
**The software starts, then exits, or the build fails.**
|
|
469
505
|
Check the build log: `elestio cicd pipeline-history <vmID> <pipelineID>`, then
|
|
470
|
-
`elestio cicd pipeline-log <vmID> --pipeline <id> --file <
|
|
506
|
+
`elestio cicd pipeline-log <vmID> --pipeline <id> --file <logID>` (or without
|
|
507
|
+
`--file` for the latest build). It answers a temporary URL tailing the log. On the compose
|
|
471
508
|
route the usual causes are a repo file the compose mounts (the CLI refuses
|
|
472
509
|
these upfront unless you passed `--force`) or a template that genuinely needs
|
|
473
510
|
its lifecycle scripts. Both need the git route, which is currently
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "elestio",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"description": "Elestio CLI - Deploy and manage services on the Elestio DevOps platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/cli.js",
|
|
7
7
|
"bin": {
|
|
8
|
-
"elestio": "
|
|
8
|
+
"elestio": "bin/elestio.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node bin/elestio.js",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
],
|
|
37
37
|
"repository": {
|
|
38
38
|
"type": "git",
|
|
39
|
-
"url": "https://github.com/elestio/elestio-cli"
|
|
39
|
+
"url": "git+https://github.com/elestio/elestio-cli.git"
|
|
40
40
|
},
|
|
41
41
|
"homepage": "https://elest.io",
|
|
42
42
|
"bugs": {
|
package/src/api.js
CHANGED
|
@@ -50,6 +50,28 @@ async function parseJson(response, endpoint) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The API throttles bursts and then answers "Access temporarily restricted"
|
|
55
|
+
* for a while, on authentication as well as on any endpoint. Saying so plainly
|
|
56
|
+
* matters: it reads like a credentials or permissions problem otherwise, and a
|
|
57
|
+
* script that treats it as "no data" silently produces wrong results.
|
|
58
|
+
*/
|
|
59
|
+
export function rateLimitMessage(message) {
|
|
60
|
+
if (!/temporarily restricted|too many requests|rate limit/i.test(String(message ?? ''))) return null;
|
|
61
|
+
return 'The Elestio API has temporarily restricted this account for sending too many requests. ' +
|
|
62
|
+
'Wait a few minutes before retrying, and space out bulk loops (one request at a time). ' +
|
|
63
|
+
`API said: "${String(message).trim()}"`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function raiseIfRateLimited(data) {
|
|
67
|
+
const message = rateLimitMessage(data?.message);
|
|
68
|
+
if (message) {
|
|
69
|
+
const error = new Error(message);
|
|
70
|
+
error.rateLimited = true;
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
53
75
|
async function authenticate(email, token) {
|
|
54
76
|
const response = await httpRequest(`${BASE_URL}/api/auth/checkAPIToken`, {
|
|
55
77
|
method: 'POST',
|
|
@@ -59,6 +81,8 @@ async function authenticate(email, token) {
|
|
|
59
81
|
|
|
60
82
|
const data = await parseJson(response, '/api/auth/checkAPIToken');
|
|
61
83
|
|
|
84
|
+
raiseIfRateLimited(data);
|
|
85
|
+
|
|
62
86
|
if (data.status !== 'OK' || !data.jwt) {
|
|
63
87
|
throw new Error(data.message || 'Authentication failed');
|
|
64
88
|
}
|
|
@@ -118,6 +142,10 @@ export async function apiRequest(endpoint, method = 'POST', body = {}, retried =
|
|
|
118
142
|
|
|
119
143
|
const data = await parseJson(response, endpoint);
|
|
120
144
|
|
|
145
|
+
// Before the auth check: a throttled account answers like a bad token, and
|
|
146
|
+
// clearing the JWT then re-authenticating only adds requests.
|
|
147
|
+
raiseIfRateLimited(data);
|
|
148
|
+
|
|
121
149
|
const isAuthError = !retried && (
|
|
122
150
|
(data.status === 'error' && data.message?.toLowerCase().includes('auth')) ||
|
|
123
151
|
(data.code === 'InvalidToken') ||
|
package/src/commands/access.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { apiRequest } from '../api.js';
|
|
2
2
|
import { loadConfig } from '../config.js';
|
|
3
|
-
import { colors, outputJson } from '../utils.js';
|
|
4
|
-
import { getServiceDetails } from './services.js';
|
|
3
|
+
import { colors, outputJson, formatTable, log } from '../utils.js';
|
|
4
|
+
import { getServiceDetails, listServicesRaw } from './services.js';
|
|
5
5
|
|
|
6
6
|
export async function getCredentials(vmID, projectId = null, json = false) {
|
|
7
7
|
const config = loadConfig();
|
|
@@ -143,3 +143,82 @@ export async function getFileExplorer(vmID, projectId = null, json = false) {
|
|
|
143
143
|
console.log('');
|
|
144
144
|
return response;
|
|
145
145
|
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* startLogTailView modes, as the backend whitelists them. "syslog" streams the
|
|
149
|
+
* app's `docker-compose logs` and is what the dashboard's Logs tab uses; an
|
|
150
|
+
* empty mode is the install log. "docker" is not accepted.
|
|
151
|
+
*/
|
|
152
|
+
export const LOG_MODES = { app: 'syslog', install: '', resync: 'resyncLog', alerts: 'alertLog', 'db-migration': 'db-migration-logs' };
|
|
153
|
+
const RAW_LOG_MODES = Object.values(LOG_MODES);
|
|
154
|
+
|
|
155
|
+
export function resolveLogMode(mode = 'app') {
|
|
156
|
+
if (mode in LOG_MODES) return LOG_MODES[mode];
|
|
157
|
+
if (RAW_LOG_MODES.includes(mode)) return mode;
|
|
158
|
+
throw new Error(`Unknown log mode "${mode}". Use one of: ${Object.keys(LOG_MODES).join(', ')}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Live log view. The API opens a temporary web page streaming the logs rather
|
|
163
|
+
* than returning them, like the dashboard's "Logs" tab.
|
|
164
|
+
*/
|
|
165
|
+
export async function getLogsView(vmID, projectId = null, mode = 'app', json = false) {
|
|
166
|
+
const pid = projectId || loadConfig().defaultProject;
|
|
167
|
+
if (!pid) throw new Error('Project ID required');
|
|
168
|
+
|
|
169
|
+
const response = await apiRequest('/api/servers/startLogTailView', 'POST', {
|
|
170
|
+
vmID: String(vmID), projectID: String(pid), mode: resolveLogMode(mode)
|
|
171
|
+
});
|
|
172
|
+
if (!response.url) throw new Error(response.message || 'Failed to open the log view');
|
|
173
|
+
|
|
174
|
+
if (json) { outputJson({ url: response.url, user: response.user, password: response.password }); return response; }
|
|
175
|
+
|
|
176
|
+
console.log(`\n${colors.bold}Live logs (${mode})${colors.reset}\n`);
|
|
177
|
+
console.log(` URL: ${colors.cyan}${response.url}${colors.reset}`);
|
|
178
|
+
if (response.user) console.log(` User: ${response.user}`);
|
|
179
|
+
if (response.password) console.log(` Password: ${response.password}`);
|
|
180
|
+
console.log(`\n ${colors.dim}Temporary page. --mode install for the installation log.${colors.reset}\n`);
|
|
181
|
+
return response;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function formatAudit(a) {
|
|
185
|
+
return {
|
|
186
|
+
when: String(a.time || a.timestamp || 'N/A').replace('T', ' ').slice(0, 19),
|
|
187
|
+
event: [a.event_category, a.event_type].filter(Boolean).join(' / ') || a.event || 'N/A',
|
|
188
|
+
user: a.email || a.userEmail || 'N/A',
|
|
189
|
+
details: String(a.event_details || a.details || '').slice(0, 60)
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Audit trail of a service: who did what, over the last N days. */
|
|
194
|
+
export async function getAudits(vmID, projectId = null, days = 30, json = false) {
|
|
195
|
+
const pid = projectId || loadConfig().defaultProject;
|
|
196
|
+
if (!pid) throw new Error('Project ID required');
|
|
197
|
+
|
|
198
|
+
// getAudits takes serverIDs, not vmIDs.
|
|
199
|
+
const service = (await listServicesRaw(pid)).find(s => String(s.vmID) === String(vmID) || String(s.id) === String(vmID));
|
|
200
|
+
if (!service) throw new Error(`Service ${vmID} not found in project ${pid}`);
|
|
201
|
+
|
|
202
|
+
const end = new Date();
|
|
203
|
+
const start = new Date(end.getTime() - Number(days) * 24 * 60 * 60 * 1000);
|
|
204
|
+
const response = await apiRequest('/api/servers/getAudits', 'POST', {
|
|
205
|
+
serverIDs: [String(service.id)], projectID: String(pid),
|
|
206
|
+
startDate: start.toISOString(), endDate: end.toISOString()
|
|
207
|
+
});
|
|
208
|
+
if (response.status === 'KO' || response.status === 'error') throw new Error(response.message || 'Failed to fetch audits');
|
|
209
|
+
|
|
210
|
+
// Entries come back as a bare array in data: {status, count, data: [...]}.
|
|
211
|
+
const audits = Array.isArray(response.data) ? response.data : (response.data?.audits || response.audits || []);
|
|
212
|
+
if (json) { outputJson(audits); return audits; }
|
|
213
|
+
if (audits.length === 0) { log('info', `No audit entries in the last ${days} days`); return []; }
|
|
214
|
+
|
|
215
|
+
console.log(`\n${colors.bold}Audit trail of ${vmID} (last ${days} days)${colors.reset}\n`);
|
|
216
|
+
console.log(formatTable(audits.map(formatAudit), [
|
|
217
|
+
{ key: 'when', label: 'When' },
|
|
218
|
+
{ key: 'event', label: 'Event' },
|
|
219
|
+
{ key: 'user', label: 'User' },
|
|
220
|
+
{ key: 'details', label: 'Details' }
|
|
221
|
+
]));
|
|
222
|
+
console.log('');
|
|
223
|
+
return audits;
|
|
224
|
+
}
|
|
@@ -4,6 +4,7 @@ import { log, colors, outputJson, formatTable } from '../utils.js';
|
|
|
4
4
|
import { getTemplates } from './templates.js';
|
|
5
5
|
import { createSubstitutions, normalizeElestioConfig, repoFileMounts } from '../templates/elestio-config.js';
|
|
6
6
|
import { buildGitPipelinePayload, buildComposePipelinePayload } from '../payloads/pipeline.js';
|
|
7
|
+
import { parseGitAuthId } from './cicd.js';
|
|
7
8
|
import { TEMPLATE_REPO_OWNER, TEMPLATE_REPO_HOST } from '../constants.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -99,12 +100,9 @@ async function resolveCicdTarget(vmID, projectId) {
|
|
|
99
100
|
};
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
async function findGitAuth(gitType
|
|
103
|
+
async function findGitAuth(gitType) {
|
|
103
104
|
const response = await apiRequest('/api/cicd/getCICDUserAuthAccount', 'POST', {});
|
|
104
|
-
|
|
105
|
-
if (!Array.isArray(accounts)) return null;
|
|
106
|
-
const match = accounts.find(a => (a.externalProviderName || a.gitType) === gitType);
|
|
107
|
-
return match ? String(match.id || match.authID) : null;
|
|
105
|
+
return parseGitAuthId(response, gitType);
|
|
108
106
|
}
|
|
109
107
|
|
|
110
108
|
/**
|
|
@@ -211,7 +209,7 @@ export async function deployTemplate(nameOrId, options = {}) {
|
|
|
211
209
|
log('success', `Config loaded: runtime "${elestioConfig.config.runTime}", ${elestioConfig.variables ? elestioConfig.variables.split('\n').length : 0} env vars, ${elestioConfig.ports.length} port(s)`);
|
|
212
210
|
|
|
213
211
|
const payload = useGit
|
|
214
|
-
? await buildGitRoute({ options, projectId, target, template, repoName, pipelineName, branch, elestioConfig })
|
|
212
|
+
? await buildGitRoute({ options, projectId, target, template, repoName, pipelineName, branch, elestioConfig, dryRun: !!options.dryRun })
|
|
215
213
|
: await buildComposeRoute({ projectId, target, repoName, pipelineName, branch, sourceRepoUrl, elestioConfig, force: options.force });
|
|
216
214
|
|
|
217
215
|
if (options.dryRun) {
|
|
@@ -239,10 +237,10 @@ export async function deployTemplate(nameOrId, options = {}) {
|
|
|
239
237
|
return response;
|
|
240
238
|
}
|
|
241
239
|
|
|
242
|
-
async function buildGitRoute({ options, projectId, target, template, repoName, pipelineName, branch, elestioConfig }) {
|
|
240
|
+
async function buildGitRoute({ options, projectId, target, template, repoName, pipelineName, branch, elestioConfig, dryRun = false }) {
|
|
243
241
|
const gitType = (options.gitType || 'GITHUB').toUpperCase();
|
|
244
242
|
|
|
245
|
-
let authID = options.authId ? String(options.authId) : await findGitAuth(gitType
|
|
243
|
+
let authID = options.authId ? String(options.authId) : await findGitAuth(gitType);
|
|
246
244
|
if (!authID) {
|
|
247
245
|
throw new Error(
|
|
248
246
|
`No ${gitType} account connected. Connect one in the dashboard (CI/CD > Git accounts), pass --auth-id, ` +
|
|
@@ -255,14 +253,21 @@ async function buildGitRoute({ options, projectId, target, template, repoName, p
|
|
|
255
253
|
throw new Error('The Git account or organisation to create the repo in is required (--owner <user-or-org>)');
|
|
256
254
|
}
|
|
257
255
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
256
|
+
// A dry run must create nothing, and this step creates a repository in the
|
|
257
|
+
// user's Git account.
|
|
258
|
+
let repo = null;
|
|
259
|
+
if (dryRun) {
|
|
260
|
+
log('info', `Would create ${owner}/${repoName} from ${TEMPLATE_REPO_OWNER}/${repoName}`);
|
|
261
|
+
} else {
|
|
262
|
+
log('info', `Creating ${owner}/${repoName} from ${TEMPLATE_REPO_OWNER}/${repoName}...`);
|
|
263
|
+
repo = await generateRepoFromTemplate({
|
|
264
|
+
gitType, authID, owner, repoName,
|
|
265
|
+
templateName: repoName,
|
|
266
|
+
isPrivate: !!options.private,
|
|
267
|
+
isNonOrg: !!options.nonOrg
|
|
268
|
+
});
|
|
269
|
+
log('success', `Repo created: ${owner}/${repoName}`);
|
|
270
|
+
}
|
|
266
271
|
|
|
267
272
|
return buildGitPipelinePayload({
|
|
268
273
|
target,
|
|
@@ -291,16 +296,17 @@ async function buildComposeRoute({ projectId, target, repoName, pipelineName, br
|
|
|
291
296
|
const fileMounts = repoFileMounts(compose);
|
|
292
297
|
if (fileMounts.length > 0 && !force) {
|
|
293
298
|
throw new Error(
|
|
294
|
-
`"${repoName}" bind-mounts ${fileMounts.length} file(s) that
|
|
295
|
-
`(${fileMounts.join(', ')})
|
|
296
|
-
'
|
|
297
|
-
'
|
|
299
|
+
`"${repoName}" bind-mounts ${fileMounts.length} file(s) that will not exist on the compose route ` +
|
|
300
|
+
`(${fileMounts.join(', ')}): either they ship in the template repo, or its install scripts create them ` +
|
|
301
|
+
'(Kafka generates its jaas/ files in preInstall). This route has no checkout and runs no install ' +
|
|
302
|
+
'script, so Docker creates each path as an empty directory and the container fails to start. The Git ' +
|
|
303
|
+
'route would handle both, but the Elestio API does not expose it today: deploy this software from the ' +
|
|
304
|
+
'dashboard, or as a managed service. --force deploys anyway, and will most likely fail.'
|
|
298
305
|
);
|
|
299
306
|
}
|
|
300
307
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
308
|
+
const hooks = installHooks(elestioConfig);
|
|
309
|
+
if (hooks.length > 0) log('warn', lifecycleWarning(repoName, hooks));
|
|
304
310
|
|
|
305
311
|
return buildComposePipelinePayload({
|
|
306
312
|
target, projectId, pipelineName, compose, elestioConfig
|
|
@@ -311,6 +317,25 @@ function hasLifecycleHooks(elestioConfig) {
|
|
|
311
317
|
return Object.values(elestioConfig.lifeCycleCommand).some(v => v && v !== '');
|
|
312
318
|
}
|
|
313
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Install hooks live in the template repo, which the compose route never
|
|
322
|
+
* checks out, so they are skipped. Every catalog template declares them, and
|
|
323
|
+
* plenty deploy perfectly without them (vaultwarden, redis and metabase were
|
|
324
|
+
* verified live), so this reports rather than refuses: what the scripts
|
|
325
|
+
* actually do cannot be read, the repos are private to Elestio.
|
|
326
|
+
*/
|
|
327
|
+
export function installHooks(elestioConfig) {
|
|
328
|
+
return Object.entries(elestioConfig.lifeCycleCommand || {})
|
|
329
|
+
.filter(([name, value]) => value && value !== '' && /^(pre|post)Install/.test(name))
|
|
330
|
+
.map(([name, value]) => `${name}=${value}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function lifecycleWarning(repoName, hooks) {
|
|
334
|
+
return `"${repoName}" declares install scripts the compose route cannot run (${hooks.join(', ')}). ` +
|
|
335
|
+
'Whatever they set up is skipped: an admin account, initial data or a sign-up lockdown may be missing. ' +
|
|
336
|
+
'Check the software once deployed; if it needs them, deploy it as a managed service instead.';
|
|
337
|
+
}
|
|
338
|
+
|
|
314
339
|
function collectOverrides(options) {
|
|
315
340
|
const overrides = {};
|
|
316
341
|
if (options.buildCmd !== undefined) overrides.buildCmd = options.buildCmd;
|
package/src/commands/cicd.js
CHANGED
|
@@ -169,16 +169,70 @@ export async function getPipelineHistory(vmID, pipelineID, projectId, json = fal
|
|
|
169
169
|
return history;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
|
|
172
|
+
/**
|
|
173
|
+
* getCICDUserAuthAccount answers an object keyed by provider:
|
|
174
|
+
* {data: {GITHUB: [{name, value, type}], GITLAB: [], GITLAB_SELF_HOSTED: []}}
|
|
175
|
+
* The auth ID is the account's `value`. Reading it as an array found nothing,
|
|
176
|
+
* so the git route reported no connected account even when there was one.
|
|
177
|
+
*/
|
|
178
|
+
export function parseGitAuthId(response, gitType) {
|
|
179
|
+
const data = response?.data ?? response ?? {};
|
|
180
|
+
|
|
181
|
+
const byProvider = data[gitType];
|
|
182
|
+
if (Array.isArray(byProvider) && byProvider.length > 0) {
|
|
183
|
+
const account = byProvider[0];
|
|
184
|
+
const id = account.value ?? account.id ?? account.authID;
|
|
185
|
+
return id === undefined || id === null ? null : String(id);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Older shape: a flat list of accounts carrying their provider.
|
|
189
|
+
const flat = Array.isArray(data) ? data : (Array.isArray(data.accounts) ? data.accounts : null);
|
|
190
|
+
if (flat) {
|
|
191
|
+
const match = flat.find(a => (a.externalProviderName || a.gitType || a.type) === gitType);
|
|
192
|
+
if (match) {
|
|
193
|
+
const id = match.value ?? match.id ?? match.authID;
|
|
194
|
+
return id === undefined || id === null ? null : String(id);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* viewPipelineLog requires isLatest, and spells the file `filePath`. Sending
|
|
203
|
+
* `filepath` without isLatest was rejected as a missing parameter, so no build
|
|
204
|
+
* log could be read. The file name is the logID from pipeline-history; it is
|
|
205
|
+
* resolved inside the pipeline's own log directory, so a full path is trimmed.
|
|
206
|
+
*/
|
|
207
|
+
export function buildViewLogPayload(vmID, pipelineID, projectId, file = null) {
|
|
208
|
+
const payload = {
|
|
209
|
+
vmID: String(vmID),
|
|
210
|
+
projectID: String(projectId),
|
|
211
|
+
pipelineID: parseInt(pipelineID),
|
|
212
|
+
isLatest: !file
|
|
213
|
+
};
|
|
214
|
+
if (file) payload.filePath = String(file).slice(String(file).lastIndexOf('/') + 1);
|
|
215
|
+
return payload;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Opens a temporary web view tailing one build log, like the dashboard does.
|
|
220
|
+
* The endpoint answers a URL, not the log's content.
|
|
221
|
+
*/
|
|
222
|
+
export async function viewPipelineLog(vmID, pipelineID, filepath, projectId, json = false) {
|
|
173
223
|
const config = loadConfig();
|
|
174
224
|
const pid = projectId || config.defaultProject;
|
|
175
225
|
if (!pid) throw new Error('Project ID required');
|
|
226
|
+
if (!pipelineID) throw new Error('Pipeline ID required (--pipeline <id>)');
|
|
176
227
|
|
|
177
|
-
const response = await apiRequest('/api/cicd/viewPipelineLog', 'POST',
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (
|
|
181
|
-
|
|
228
|
+
const response = await apiRequest('/api/cicd/viewPipelineLog', 'POST', buildViewLogPayload(vmID, pipelineID, pid, filepath));
|
|
229
|
+
if (!response.url) throw new Error(response.message || response.details || 'Failed to open the build log');
|
|
230
|
+
|
|
231
|
+
if (json) { outputJson({ url: response.url }); return response; }
|
|
232
|
+
|
|
233
|
+
console.log(`\n${colors.bold}Build log${filepath ? ` (${filepath})` : ' (latest)'}${colors.reset}\n`);
|
|
234
|
+
console.log(` URL: ${colors.cyan}${response.url}${colors.reset}`);
|
|
235
|
+
console.log(`\n ${colors.dim}Temporary page tailing the log on the target.${colors.reset}\n`);
|
|
182
236
|
return response;
|
|
183
237
|
}
|
|
184
238
|
|
|
@@ -238,6 +292,14 @@ async function findGitAuthID(gitType, projectId) {
|
|
|
238
292
|
const config = loadConfig();
|
|
239
293
|
const pid = projectId || config.defaultProject;
|
|
240
294
|
|
|
295
|
+
// The connected accounts are the authoritative source; the scan below only
|
|
296
|
+
// covers accounts that are not listed but are in use by a pipeline.
|
|
297
|
+
try {
|
|
298
|
+
const accounts = await apiRequest('/api/cicd/getCICDUserAuthAccount', 'POST', {});
|
|
299
|
+
const authID = parseGitAuthId(accounts, gitType);
|
|
300
|
+
if (authID) return authID;
|
|
301
|
+
} catch { /* fall through to the scan */ }
|
|
302
|
+
|
|
241
303
|
try {
|
|
242
304
|
const cicdResp = await apiRequest('/api/cicd/getCICDServices', 'POST', { projectID: String(pid) });
|
|
243
305
|
const cicdServices = Array.isArray(cicdResp) ? cicdResp : (cicdResp.data?.services || []);
|
package/src/commands/clusters.js
CHANGED
|
@@ -6,7 +6,8 @@ import {
|
|
|
6
6
|
minClusterNodes, supportsClustering, supportsMultiMaster
|
|
7
7
|
} from '../constants.js';
|
|
8
8
|
import { getTemplates } from './templates.js';
|
|
9
|
-
import {
|
|
9
|
+
import { doAction } from './actions.js';
|
|
10
|
+
import { listServicesRaw, deleteService, getServiceDetails } from './services.js';
|
|
10
11
|
|
|
11
12
|
const APPID = 'CloudVM';
|
|
12
13
|
|
|
@@ -277,6 +278,257 @@ export async function promoteNode(clusterId, vmID, projectId, force) {
|
|
|
277
278
|
return response;
|
|
278
279
|
}
|
|
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
|
+
|
|
280
532
|
/**
|
|
281
533
|
* There is no cluster delete endpoint: deleting the primary service deletes
|
|
282
534
|
* every node with it. The primary changes after a promote, so it is looked up
|
package/src/registry.js
CHANGED
|
@@ -347,6 +347,44 @@ export const registry = {
|
|
|
347
347
|
summary: 'Re-sync replicas from the primary, erasing replica data (--force)', usage: 'resync <clusterID> --force',
|
|
348
348
|
async run({ args }) { await (await load.clusters()).resyncCluster(requireArg(args._[2], 'clusters resync <clusterID> --force'), args.project, !!args.force); }
|
|
349
349
|
},
|
|
350
|
+
'add-node': {
|
|
351
|
+
summary: 'Add a node, copying the primary (--size/--region to change; --dry-run)', usage: 'add-node <clusterID>',
|
|
352
|
+
async run({ args, json }) {
|
|
353
|
+
await (await load.clusters()).addClusterNode(requireArg(args._[2], 'clusters add-node <clusterID>'), {
|
|
354
|
+
project: args.project, size: args.size, region: args.region, provider: args.provider,
|
|
355
|
+
version: args.version, email: args.email, dryRun: !!args['dry-run'], json
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
'remove-node': {
|
|
360
|
+
summary: 'Remove a replica node and its VM (--force)', usage: 'remove-node <clusterID> <vmID> --force',
|
|
361
|
+
async run({ args }) {
|
|
362
|
+
await (await load.clusters()).removeClusterNode(
|
|
363
|
+
requireArg(args._[2], 'clusters remove-node <clusterID> <vmID> --force'),
|
|
364
|
+
requireArg(args._[3], 'clusters remove-node <clusterID> <vmID> --force'),
|
|
365
|
+
args.project, !!args.force
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
firewall: {
|
|
370
|
+
summary: 'Show which IPs each port of a cluster accepts', usage: 'firewall <clusterID>',
|
|
371
|
+
async run({ args, json }) { await (await load.clusters()).showClusterFirewall(requireArg(args._[2], 'clusters firewall <clusterID>'), args.project, json); }
|
|
372
|
+
},
|
|
373
|
+
'firewall-restrict': {
|
|
374
|
+
summary: 'Only accept a port from given IPs, on every node', usage: 'firewall-restrict <clusterID> --port P --ips ip1,ip2',
|
|
375
|
+
async run({ args }) {
|
|
376
|
+
const { setClusterPortAccess, parseIpList } = await load.clusters();
|
|
377
|
+
const ips = parseIpList(args.ips);
|
|
378
|
+
if (ips.length === 0) throw new Error('--ips is required (comma-separated IPs or CIDR ranges). To reopen a port use firewall-open.');
|
|
379
|
+
await setClusterPortAccess(requireArg(args._[2], 'clusters firewall-restrict <clusterID> --port P --ips ...'), requireArg(args.port, '--port <port>'), ips, args.project);
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
'firewall-open': {
|
|
383
|
+
summary: 'Open a port of a cluster to everyone again', usage: 'firewall-open <clusterID> --port P',
|
|
384
|
+
async run({ args }) {
|
|
385
|
+
await (await load.clusters()).setClusterPortAccess(requireArg(args._[2], 'clusters firewall-open <clusterID> --port P'), requireArg(args.port, '--port <port>'), [], args.project);
|
|
386
|
+
}
|
|
387
|
+
},
|
|
350
388
|
delete: {
|
|
351
389
|
summary: 'Delete a cluster and all its nodes (--force)', usage: 'delete <clusterID> --force',
|
|
352
390
|
async run({ args }) { await (await load.clusters()).deleteCluster(requireArg(args._[2], 'clusters delete <clusterID> --force'), args.project, !!args.force); }
|
|
@@ -507,6 +545,20 @@ export const registry = {
|
|
|
507
545
|
|
|
508
546
|
// ── Access ──
|
|
509
547
|
|
|
548
|
+
logs: {
|
|
549
|
+
group: 'Access',
|
|
550
|
+
summary: 'Open a live log view of a service (--mode install for the install log)',
|
|
551
|
+
usage: 'logs <vmID> [--mode app|install]',
|
|
552
|
+
async run({ args, json }) { await (await load.access()).getLogsView(requireArg(args._[1], 'logs <vmID>'), args.project, args.mode || 'app', json); }
|
|
553
|
+
},
|
|
554
|
+
|
|
555
|
+
audits: {
|
|
556
|
+
group: 'Access',
|
|
557
|
+
summary: 'Show the audit trail of a service (--days, default 30)',
|
|
558
|
+
usage: 'audits <vmID> [--days N]',
|
|
559
|
+
async run({ args, json }) { await (await load.access()).getAudits(requireArg(args._[1], 'audits <vmID>'), args.project, args.days ? Number(args.days) : 30, json); }
|
|
560
|
+
},
|
|
561
|
+
|
|
510
562
|
credentials: {
|
|
511
563
|
group: 'Access',
|
|
512
564
|
summary: 'Show the application credentials of a service',
|
|
@@ -602,7 +654,7 @@ export const registry = {
|
|
|
602
654
|
'pipeline-resync': { summary: 'Re-sync a pipeline', usage: 'pipeline-resync <vmID> <pipelineID>', async run({ args }) { await (await load.cicd()).resyncPipeline(requireArg(args._[2], 'cicd pipeline-resync <vmID> <pipelineID>'), positional(args, 3, 'pipeline'), args.project); } },
|
|
603
655
|
'pipeline-logs': { summary: 'Tail the running pipeline logs', usage: 'pipeline-logs <vmID> <pipelineID>', async run({ args }) { await (await load.cicd()).getPipelineLogs(requireArg(args._[2], 'cicd pipeline-logs <vmID> <pipelineID>'), positional(args, 3, 'pipeline'), args.project); } },
|
|
604
656
|
'pipeline-history': { summary: 'Show the build history', usage: 'pipeline-history <vmID> <pipelineID>', async run({ args, json }) { await (await load.cicd()).getPipelineHistory(requireArg(args._[2], 'cicd pipeline-history <vmID> <pipelineID>'), positional(args, 3, 'pipeline'), args.project, json); } },
|
|
605
|
-
'pipeline-log': { summary: '
|
|
657
|
+
'pipeline-log': { summary: 'Open a build log (latest, or --file <logID> from pipeline-history)', usage: 'pipeline-log <vmID> --pipeline <id> [--file <logID>]', async run({ args, json }) { await (await load.cicd()).viewPipelineLog(requireArg(args._[2], 'cicd pipeline-log <vmID> --pipeline <id> [--file <logID>]'), args.pipeline, args.file, args.project, json); } },
|
|
606
658
|
domains: { summary: 'List pipeline domains', usage: 'domains <vmID> <pipelineID>', async run({ args, json }) { await (await load.cicd()).listPipelineDomains(requireArg(args._[2], 'cicd domains <vmID> <pipelineID>'), positional(args, 3, 'pipeline'), args.project, json); } },
|
|
607
659
|
'domain-add': { summary: 'Add a pipeline domain', usage: 'domain-add <vmID> --pipeline <id> --domain <d>', async run({ args }) { await (await load.cicd()).addPipelineDomain(requireArg(args._[2], 'cicd domain-add <vmID> --pipeline <id> --domain <d>'), args.pipeline, args.domain || args._[3], args.project); } },
|
|
608
660
|
'domain-remove': { summary: 'Remove a pipeline domain', usage: 'domain-remove <vmID> --pipeline <id> --domain <d>', async run({ args }) { await (await load.cicd()).removePipelineDomain(requireArg(args._[2], 'cicd domain-remove <vmID> --pipeline <id> --domain <d>'), args.pipeline, args.domain || args._[3], args.project); } },
|
|
@@ -179,10 +179,22 @@ export function repoFileMounts(compose) {
|
|
|
179
179
|
if (typeof compose !== 'string') return [];
|
|
180
180
|
|
|
181
181
|
const mounts = new Set();
|
|
182
|
-
|
|
183
|
-
const source = match[1];
|
|
182
|
+
const add = (source) => {
|
|
184
183
|
const lastSegment = source.slice(source.lastIndexOf('/') + 1);
|
|
185
184
|
if (/\.[A-Za-z0-9]{1,10}$/.test(lastSegment)) mounts.add(source);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Short syntax, quoted or not: - ./file:/in/container[:ro]
|
|
188
|
+
// Many templates quote their mounts (Chromadb among them), and the
|
|
189
|
+
// unquoted-only pattern let those through silently.
|
|
190
|
+
for (const match of compose.matchAll(/^\s*-\s+["']?(\.{1,2}\/[^\s:"']+)["']?\s*:/gm)) {
|
|
191
|
+
add(match[1]);
|
|
186
192
|
}
|
|
193
|
+
|
|
194
|
+
// Long syntax: - type: bind / source: ./file / target: /in/container
|
|
195
|
+
for (const match of compose.matchAll(/^\s*source:\s*["']?(\.{1,2}\/[^\s"']+)["']?\s*$/gm)) {
|
|
196
|
+
add(match[1]);
|
|
197
|
+
}
|
|
198
|
+
|
|
187
199
|
return [...mounts];
|
|
188
200
|
}
|