elestio 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
 
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "elestio",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
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": "./bin/elestio.js"
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": {
@@ -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
+ }
@@ -6,7 +6,8 @@ import {
6
6
  minClusterNodes, supportsClustering, supportsMultiMaster
7
7
  } from '../constants.js';
8
8
  import { getTemplates } from './templates.js';
9
- import { listServicesRaw, deleteService } from './services.js';
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',