clever-tools 4.8.0 → 4.9.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.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/src/clever-client/k8s.js +197 -0
  3. package/src/commands/addon/addon.docs.md +1 -1
  4. package/src/commands/global.commands.js +34 -0
  5. package/src/commands/global.options.js +1 -1
  6. package/src/commands/k8s/k8s.activity.command.js +53 -0
  7. package/src/commands/k8s/k8s.args.js +6 -0
  8. package/src/commands/k8s/k8s.create.command.js +87 -3
  9. package/src/commands/k8s/k8s.delete.command.js +13 -18
  10. package/src/commands/k8s/k8s.docs.md +264 -1
  11. package/src/commands/k8s/k8s.get.command.js +65 -4
  12. package/src/commands/k8s/k8s.nodegroups.command.js +7 -0
  13. package/src/commands/k8s/k8s.nodegroups.create.command.js +79 -0
  14. package/src/commands/k8s/k8s.nodegroups.delete.command.js +34 -0
  15. package/src/commands/k8s/k8s.nodegroups.get.command.js +66 -0
  16. package/src/commands/k8s/k8s.nodegroups.list.command.js +49 -0
  17. package/src/commands/k8s/k8s.nodegroups.update.command.js +86 -0
  18. package/src/commands/k8s/k8s.quota.command.js +77 -0
  19. package/src/commands/k8s/k8s.update.command.js +72 -0
  20. package/src/commands/k8s/k8s.version.check.command.js +18 -0
  21. package/src/commands/k8s/k8s.version.command.js +18 -0
  22. package/src/commands/k8s/k8s.version.update.command.js +18 -0
  23. package/src/commands/oauth-consumers/oauth-consumers.docs.md +1 -1
  24. package/src/commands/otoroshi/otoroshi.docs.md +14 -0
  25. package/src/commands/otoroshi/otoroshi.open.swaggerui.command.js +13 -0
  26. package/src/lib/k8s.js +411 -11
  27. package/src/lib/operator-commands.js +15 -1
  28. package/src/lib/prompts.js +6 -2
  29. package/src/parsers.js +10 -0
package/src/lib/k8s.js CHANGED
@@ -1,13 +1,28 @@
1
+ import dedent from 'dedent';
2
+ import { ask, confirm, selectAnswer } from './prompts.js';
1
3
  import { styleText } from './style-text.js';
2
4
 
3
5
  import {
4
6
  addK8sPersistentStorage,
5
7
  createK8sCluster,
8
+ createK8sNodeGroup,
6
9
  deleteK8sCluster,
10
+ deleteK8sNodeGroup,
7
11
  getK8sAddon,
8
12
  getK8sConfig,
13
+ getK8sNodeGroup,
14
+ getK8sProduct,
15
+ getK8sQuota,
16
+ getK8sVersionCheck,
9
17
  listK8sClusters,
18
+ listK8sDeploymentEvents,
19
+ listK8sNodeGroups,
20
+ listK8sUsage,
21
+ updateK8sCluster,
22
+ updateK8sNodeGroup,
23
+ updateK8sVersion,
10
24
  } from '../clever-client/k8s.js';
25
+ import { Logger } from '../logger.js';
11
26
  import { getOwnerIdFromOrgIdOrName } from '../models/ids-resolver.js';
12
27
  import { sendToApi } from '../models/send-to-api.js';
13
28
 
@@ -28,13 +43,119 @@ export async function isK8sClusterActive(orgIdOrName, clusterIdOrName) {
28
43
  /**
29
44
  * Create a kubernetes cluster
30
45
  * @param {string} name The name of the cluster
31
- * @param {string} ownerId The owner ID
32
- * @returns {Promise<void>}
46
+ * @param {object} orgIdOrName The organisation ID or name
47
+ * @param {object} [options]
48
+ * @param {string} [options.version] The Kubernetes version to deploy
49
+ * @param {string} [options.description] A free-form description
50
+ * @param {string[]} [options.tags] Semantic tags ("tag" or "key:value")
51
+ * @param {boolean} [options.autoscaling] Enable the cluster autoscaler
52
+ * @param {boolean} [options.persistentStorage] Enable the Ceph CSI persistent storage
53
+ * @param {string} [options.topology] Topology kind (ALL_IN_ONE, DEDICATED_COMPUTE, DISTRIBUTED)
54
+ * @param {string} [options.flavor] Control plane flavor
55
+ * @param {number} [options.replicationFactor] Control plane replication factor
56
+ * @param {{flavor: string, targetNodeCount: number}} [options.nodeGroup] Initial node group
57
+ * @returns {Promise<object>}
33
58
  */
34
- export async function k8sCreate(name, ownerId) {
35
- ownerId = await getOwnerIdFromOrgIdOrName(ownerId);
59
+ export async function k8sCreate(name, orgIdOrName, options = {}) {
60
+ const ownerId = await getOwnerIdFromOrgIdOrName(orgIdOrName);
61
+ const product = await k8sGetProduct();
62
+
63
+ const body = { name };
64
+ if (options.version != null) {
65
+ const available = product.versions?.available ?? [];
66
+ if (!available.includes(options.version)) {
67
+ throw new Error(`Version "${options.version}" is not available. Supported: ${available.join(', ')}`);
68
+ }
69
+ body.version = options.version;
70
+ }
71
+ if (options.description != null) body.description = options.description;
72
+ if (options.tags?.length) body.tags = options.tags;
73
+
74
+ const features = {};
75
+ if (options.autoscaling) features.autoscalingEnabled = true;
76
+ if (options.persistentStorage) features.csi = true;
77
+ if (Object.keys(features).length > 0) body.features = features;
78
+
79
+ body.topologyConfig = resolveTopologyConfig(options, product);
80
+
81
+ if (options.nodeGroup != null) {
82
+ const supported = getNodeGroupFlavors(product);
83
+ if (!supported.includes(options.nodeGroup.flavor)) {
84
+ throw new Error(
85
+ `Flavor "${options.nodeGroup.flavor}" is not a valid node group flavor. Supported: ${supported.join(', ')}`,
86
+ );
87
+ }
88
+ let addNodeGroup = true;
89
+ if (body.topologyConfig.topology === 'ALL_IN_ONE' && !options.yes) {
90
+ Logger.println(
91
+ styleText(
92
+ 'yellow',
93
+ '⚠️ ALL_IN_ONE topology already schedules pods on control plane VMs — an additional node group is usually unnecessary.',
94
+ ),
95
+ );
96
+ addNodeGroup = await ask('Add the node group anyway?', false);
97
+ if (!addNodeGroup) {
98
+ Logger.println('Node group creation skipped, cluster will be deployed without it');
99
+ }
100
+ }
101
+ if (addNodeGroup) {
102
+ body.nodeGroups = [
103
+ { name: 'default', flavor: options.nodeGroup.flavor, targetNodeCount: options.nodeGroup.targetNodeCount },
104
+ ];
105
+ }
106
+ }
36
107
 
37
- return createK8sCluster({ ownerId }, { name }).then(sendToApi);
108
+ return createK8sCluster({ ownerId }, body).then(sendToApi);
109
+ }
110
+
111
+ const FLAVOR_ORDER = ['2XS', 'XS', 'S', 'M', 'L', 'XL'];
112
+ const DEFAULT_TOPOLOGY = 'ALL_IN_ONE';
113
+ const DISTRIBUTED_COMPONENTS = [
114
+ 'apiserver',
115
+ 'controllerManager',
116
+ 'scheduler',
117
+ 'nodeGroupOperator',
118
+ 'cloudControllerManager',
119
+ ];
120
+
121
+ function resolveTopologyConfig({ topology, flavor, replicationFactor }, product) {
122
+ const resolvedTopology = topology ?? DEFAULT_TOPOLOGY;
123
+ const constraint = product.topologies?.find((t) => t.topology === resolvedTopology);
124
+ if (constraint == null) {
125
+ const supported = (product.topologies ?? []).map((t) => t.topology).join(', ');
126
+ throw new Error(`Unknown topology "${resolvedTopology}". Supported: ${supported}`);
127
+ }
128
+
129
+ const resolvedFlavor = flavor ?? FLAVOR_ORDER.find((f) => constraint.availableFlavors?.includes(f));
130
+ if (resolvedFlavor == null || !constraint.availableFlavors?.includes(resolvedFlavor)) {
131
+ throw new Error(
132
+ `Flavor "${resolvedFlavor}" is not available for ${resolvedTopology}. Supported: ${(constraint.availableFlavors ?? []).join(', ')}`,
133
+ );
134
+ }
135
+
136
+ const { min, max } = constraint.replicationFactor;
137
+ const resolvedRf = replicationFactor ?? min;
138
+ if (resolvedRf < min || resolvedRf > max) {
139
+ throw new Error(`Replication factor for ${resolvedTopology} must be between ${min} and ${max}`);
140
+ }
141
+
142
+ if (resolvedTopology === 'DISTRIBUTED') {
143
+ const component = { flavor: resolvedFlavor, replicationFactor: resolvedRf };
144
+ return {
145
+ topology: 'DISTRIBUTED',
146
+ components: Object.fromEntries(DISTRIBUTED_COMPONENTS.map((c) => [c, component])),
147
+ };
148
+ }
149
+
150
+ return { topology: resolvedTopology, flavor: resolvedFlavor, replicationFactor: resolvedRf };
151
+ }
152
+
153
+ /**
154
+ * Get the Kubernetes service configuration (supported topologies, flavors, versions)
155
+ * @returns {Promise<object>}
156
+ */
157
+ export async function k8sGetProduct() {
158
+ return getK8sProduct().then(sendToApi);
38
159
  }
39
160
 
40
161
  /**
@@ -75,6 +196,20 @@ export async function k8sGetConfig(orgIdOrName, clusterIdOrName) {
75
196
  return getK8sConfig({ ownerId, clusterId }).then(sendToApi);
76
197
  }
77
198
 
199
+ /**
200
+ * Update a Kubernetes cluster metadata or features
201
+ * @param {object} orgIdOrName The organisation ID or name
202
+ * @param {string|object} clusterIdOrName The cluster ID or name
203
+ * @param {object} updates Patch fields (name, description, tags, features)
204
+ * @returns {Promise<object>}
205
+ */
206
+ export async function k8sUpdate(orgIdOrName, clusterIdOrName, updates) {
207
+ const ownerId = await getOwnerIdFromOrgIdOrName(orgIdOrName);
208
+ const clusterId = await getClusterIdFromAddonIdOrName(clusterIdOrName, ownerId);
209
+
210
+ return updateK8sCluster({ ownerId, clusterId }, updates).then(sendToApi);
211
+ }
212
+
78
213
  /**
79
214
  * Delete a kubernetes cluster
80
215
  * @param {string} orgIdOrName The organisation ID or name
@@ -100,13 +235,21 @@ export async function getClusterIdFromAddonIdOrName(addonIdOrName, ownerId) {
100
235
  } else if (typeof addonIdOrName === 'object' && addonIdOrName.operator_id) {
101
236
  return addonIdOrName.operator_id;
102
237
  } else if (typeof addonIdOrName === 'object' && addonIdOrName.addon_name) {
103
- const clusters = await listK8sClusters({ ownerId }).then(sendToApi);
104
- const matchingCluster = clusters.find((cluster) => cluster.name === addonIdOrName.addon_name);
105
- if (matchingCluster) {
106
- return matchingCluster.id;
107
- } else {
108
- throw new Error(`No Kubernetes cluster found with the name ${styleText('red', addonIdOrName.addon_name)}`);
238
+ const name = addonIdOrName.addon_name;
239
+ const matches = await listK8sClusters({ ownerId })
240
+ .then(sendToApi)
241
+ .then((clusters) => clusters.filter((c) => c.name === name && c.status !== 'DELETED'));
242
+
243
+ if (matches.length === 0) {
244
+ throw new Error(`No Kubernetes cluster found with the name ${styleText('red', name)}`);
245
+ }
246
+ if (matches.length > 1) {
247
+ const listing = matches.map((c) => `- ${c.name} (${c.id})`).join('\n');
248
+ throw new Error(
249
+ `Multiple Kubernetes clusters found with the name ${styleText('red', name)}, use the ID instead:\n${styleText('grey', listing)}`,
250
+ );
109
251
  }
252
+ return matches[0].id;
110
253
  } else {
111
254
  throw new Error('Invalid Kubernetes Cluster identifier provided');
112
255
  }
@@ -124,3 +267,260 @@ export async function k8sAddPersistentStorage(orgIdOrName, clusterIdOrName) {
124
267
 
125
268
  return addK8sPersistentStorage({ ownerId, clusterId }).then(sendToApi);
126
269
  }
270
+
271
+ /**
272
+ * Get the Kubernetes quota of an organisation
273
+ * @param {object} [orgIdOrName] The organisation ID or name
274
+ * @returns {Promise<object>} The quota payload (id, tenantId, tags, quotas)
275
+ */
276
+ export async function k8sGetQuota(orgIdOrName) {
277
+ const ownerId = await getOwnerIdFromOrgIdOrName(orgIdOrName);
278
+
279
+ return getK8sQuota({ ownerId }).then(sendToApi);
280
+ }
281
+
282
+ /**
283
+ * List the current Kubernetes usage items of an organisation
284
+ * @param {object} [orgIdOrName] The organisation ID or name
285
+ * @returns {Promise<object[]>} The list of cluster usage items
286
+ */
287
+ export async function k8sListUsage(orgIdOrName) {
288
+ const ownerId = await getOwnerIdFromOrgIdOrName(orgIdOrName);
289
+
290
+ return listK8sUsage({ ownerId }).then(sendToApi);
291
+ }
292
+
293
+ /**
294
+ * List deployment events for a Kubernetes cluster
295
+ * @param {object} orgIdOrName The organisation ID or name
296
+ * @param {string|object} clusterIdOrName The cluster ID or name
297
+ * @param {number} [limit] Max number of events to return
298
+ * @returns {Promise<object[]>}
299
+ */
300
+ export async function k8sListActivity(orgIdOrName, clusterIdOrName, limit) {
301
+ const ownerId = await getOwnerIdFromOrgIdOrName(orgIdOrName);
302
+ const clusterId = await getClusterIdFromAddonIdOrName(clusterIdOrName, ownerId);
303
+ const queryParams = limit != null ? { limit } : {};
304
+
305
+ return listK8sDeploymentEvents({ ownerId, clusterId }, queryParams).then(sendToApi);
306
+ }
307
+
308
+ /**
309
+ * List the node groups of a Kubernetes cluster
310
+ * @param {object} orgIdOrName The organisation ID or name
311
+ * @param {string|object} clusterIdOrName The cluster ID or name
312
+ * @returns {Promise<object[]>}
313
+ */
314
+ export async function k8sListNodeGroups(orgIdOrName, clusterIdOrName) {
315
+ const ownerId = await getOwnerIdFromOrgIdOrName(orgIdOrName);
316
+ const clusterId = await getClusterIdFromAddonIdOrName(clusterIdOrName, ownerId);
317
+
318
+ return listK8sNodeGroups({ ownerId, clusterId }).then(sendToApi);
319
+ }
320
+
321
+ /**
322
+ * Create a node group on a Kubernetes cluster
323
+ * @param {object} orgIdOrName The organisation ID or name
324
+ * @param {string|object} clusterIdOrName The cluster ID or name
325
+ * @param {object} options
326
+ * @param {string} options.name Node group name
327
+ * @param {string} options.flavor Node flavor (2XS..XL)
328
+ * @param {number} options.targetNodeCount Target node count
329
+ * @param {string} [options.description]
330
+ * @param {string} [options.tag]
331
+ * @param {boolean} [options.autoscaling]
332
+ * @param {number} [options.min] Minimum node count (autoscaling)
333
+ * @param {number} [options.max] Maximum node count (autoscaling)
334
+ * @returns {Promise<object>}
335
+ */
336
+ export async function k8sCreateNodeGroup(orgIdOrName, clusterIdOrName, options) {
337
+ const ownerId = await getOwnerIdFromOrgIdOrName(orgIdOrName);
338
+ const clusterId = await getClusterIdFromAddonIdOrName(clusterIdOrName, ownerId);
339
+ const product = await k8sGetProduct();
340
+
341
+ const supported = getNodeGroupFlavors(product);
342
+ if (!supported.includes(options.flavor)) {
343
+ throw new Error(`Flavor "${options.flavor}" is not a valid node group flavor. Supported: ${supported.join(', ')}`);
344
+ }
345
+
346
+ const wantsAutoscaling = options.autoscaling || options.min != null || options.max != null;
347
+ if (wantsAutoscaling && (options.min == null || options.max == null)) {
348
+ throw new Error('--autoscaling requires both --min and --max');
349
+ }
350
+ if (wantsAutoscaling && options.min > options.max) {
351
+ throw new Error('--min must be less than or equal to --max');
352
+ }
353
+
354
+ const body = { name: options.name, flavor: options.flavor, targetNodeCount: options.targetNodeCount };
355
+ if (options.description != null) body.description = options.description;
356
+ if (options.tag != null) body.tag = options.tag;
357
+ if (wantsAutoscaling) {
358
+ body.autoscalingEnabled = true;
359
+ body.minNodeCount = options.min;
360
+ body.maxNodeCount = options.max;
361
+ }
362
+
363
+ return createK8sNodeGroup({ ownerId, clusterId }, body).then(sendToApi);
364
+ }
365
+
366
+ function getNodeGroupFlavors(product) {
367
+ const available = new Set((product.topologies ?? []).flatMap((t) => t.availableFlavors ?? []));
368
+ return FLAVOR_ORDER.filter((f) => available.has(f));
369
+ }
370
+
371
+ /**
372
+ * Update a node group on a Kubernetes cluster
373
+ * @param {object} orgIdOrName The organisation ID or name
374
+ * @param {string|object} clusterIdOrName The cluster ID or name
375
+ * @param {string} nodeGroupIdOrName The node group ID or name
376
+ * @param {object} updates Patch fields (targetNodeCount, minNodeCount, maxNodeCount, autoscalingEnabled, description, tag)
377
+ * @returns {Promise<object>}
378
+ */
379
+ export async function k8sUpdateNodeGroup(orgIdOrName, clusterIdOrName, nodeGroupIdOrName, updates) {
380
+ const ownerId = await getOwnerIdFromOrgIdOrName(orgIdOrName);
381
+ const clusterId = await getClusterIdFromAddonIdOrName(clusterIdOrName, ownerId);
382
+ const nodeGroupId = await resolveNodeGroupId(ownerId, clusterId, nodeGroupIdOrName);
383
+ const current = await getK8sNodeGroup({ ownerId, clusterId, nodeGroupId }).then(sendToApi);
384
+
385
+ const body = { name: current.name, targetNodeCount: current.targetNodeCount, ...updates };
386
+
387
+ return updateK8sNodeGroup({ ownerId, clusterId, nodeGroupId }, body).then(sendToApi);
388
+ }
389
+
390
+ /**
391
+ * Delete a node group from a Kubernetes cluster
392
+ * @param {object} orgIdOrName The organisation ID or name
393
+ * @param {string|object} clusterIdOrName The cluster ID or name
394
+ * @param {string} nodeGroupIdOrName The node group ID or name
395
+ * @returns {Promise<void>}
396
+ */
397
+ export async function k8sDeleteNodeGroup(orgIdOrName, clusterIdOrName, nodeGroupIdOrName) {
398
+ const ownerId = await getOwnerIdFromOrgIdOrName(orgIdOrName);
399
+ const clusterId = await getClusterIdFromAddonIdOrName(clusterIdOrName, ownerId);
400
+ const nodeGroupId = await resolveNodeGroupId(ownerId, clusterId, nodeGroupIdOrName);
401
+
402
+ return deleteK8sNodeGroup({ ownerId, clusterId, nodeGroupId }).then(sendToApi);
403
+ }
404
+
405
+ /**
406
+ * Get a specific node group of a Kubernetes cluster
407
+ * @param {object} orgIdOrName The organisation ID or name
408
+ * @param {string|object} clusterIdOrName The cluster ID or name
409
+ * @param {string} nodeGroupIdOrName The node group ID or name
410
+ * @returns {Promise<object>}
411
+ */
412
+ export async function k8sGetNodeGroup(orgIdOrName, clusterIdOrName, nodeGroupIdOrName) {
413
+ const ownerId = await getOwnerIdFromOrgIdOrName(orgIdOrName);
414
+ const clusterId = await getClusterIdFromAddonIdOrName(clusterIdOrName, ownerId);
415
+ const nodeGroupId = await resolveNodeGroupId(ownerId, clusterId, nodeGroupIdOrName);
416
+
417
+ return getK8sNodeGroup({ ownerId, clusterId, nodeGroupId }).then(sendToApi);
418
+ }
419
+
420
+ const NODE_GROUP_ID_REGEX = /^node_group_[0-9A-HJ-NP-TV-Z]{26}$/i;
421
+
422
+ /**
423
+ * Resolve a node group ID from either an ID or a name (scoped to a cluster)
424
+ * @param {string} ownerId
425
+ * @param {string} clusterId
426
+ * @param {string} nodeGroupIdOrName
427
+ * @returns {Promise<string>}
428
+ */
429
+ async function resolveNodeGroupId(ownerId, clusterId, nodeGroupIdOrName) {
430
+ if (NODE_GROUP_ID_REGEX.test(nodeGroupIdOrName)) {
431
+ return nodeGroupIdOrName;
432
+ }
433
+ const list = await listK8sNodeGroups({ ownerId, clusterId }).then(sendToApi);
434
+ const matches = list.filter((ng) => ng.name === nodeGroupIdOrName);
435
+ if (matches.length === 0) {
436
+ throw new Error(`No node group found with name ${styleText('red', nodeGroupIdOrName)}`);
437
+ }
438
+ if (matches.length > 1) {
439
+ const listing = matches.map((ng) => `- ${ng.name} (${ng.id})`).join('\n');
440
+ throw new Error(
441
+ `Multiple node groups found with the name ${styleText('red', nodeGroupIdOrName)}, use the ID instead:\n${styleText('grey', listing)}`,
442
+ );
443
+ }
444
+ return matches[0].id;
445
+ }
446
+
447
+ /**
448
+ * Check a Kubernetes cluster version against available upgrades
449
+ * @param {object} orgIdOrName The organisation ID or name
450
+ * @param {string|object} clusterIdOrName The cluster ID or name
451
+ * @param {string} format The output format
452
+ * @returns {Promise<void>}
453
+ */
454
+ export async function k8sCheckVersion(orgIdOrName, clusterIdOrName, format) {
455
+ const ownerId = await getOwnerIdFromOrgIdOrName(orgIdOrName);
456
+ const clusterId = await getClusterIdFromAddonIdOrName(clusterIdOrName, ownerId);
457
+ const name = getClusterDisplayName(clusterIdOrName, clusterId);
458
+ const versions = await getK8sVersionCheck({ ownerId, clusterId }).then(sendToApi);
459
+
460
+ switch (format) {
461
+ case 'json':
462
+ Logger.printJson(versions);
463
+ break;
464
+ case 'human':
465
+ default:
466
+ if (!versions.needUpdate) {
467
+ Logger.printSuccess(`${styleText('green', name)} is up-to-date (${styleText('green', versions.installed)})`);
468
+ } else {
469
+ Logger.println(dedent`
470
+ 🔄 ${styleText('red', name)} is outdated
471
+ • Installed version: ${styleText('red', versions.installed)}
472
+ • Latest version: ${styleText('green', versions.latest)}
473
+ `);
474
+ Logger.println();
475
+
476
+ await confirm(
477
+ `Do you want to update it to ${styleText('green', versions.latest)} now?`,
478
+ 'No confirmation, aborting version update',
479
+ );
480
+
481
+ await updateK8sVersion({ ownerId, clusterId }, { targetVersion: versions.latest }).then(sendToApi);
482
+ Logger.printSuccess(`${styleText('green', name)} is upgrading to ${styleText('green', versions.latest)}…`);
483
+ }
484
+ break;
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Update a Kubernetes cluster version
490
+ * @param {object} orgIdOrName The organisation ID or name
491
+ * @param {string|object} clusterIdOrName The cluster ID or name
492
+ * @param {string} [askedVersion] The target version; prompts from available versions when omitted
493
+ * @returns {Promise<void>}
494
+ */
495
+ export async function k8sUpdateVersion(orgIdOrName, clusterIdOrName, askedVersion) {
496
+ const ownerId = await getOwnerIdFromOrgIdOrName(orgIdOrName);
497
+ const clusterId = await getClusterIdFromAddonIdOrName(clusterIdOrName, ownerId);
498
+ const name = getClusterDisplayName(clusterIdOrName, clusterId);
499
+ const versions = await getK8sVersionCheck({ ownerId, clusterId }).then(sendToApi);
500
+
501
+ const targetVersion =
502
+ askedVersion ??
503
+ (await selectAnswer(
504
+ `Which version do you want to update ${styleText('blue', name)} to, current is ${styleText('blue', versions.installed)}?`,
505
+ [...versions.available].reverse(),
506
+ ));
507
+
508
+ if (!versions.available.includes(targetVersion)) {
509
+ throw new Error(`Version ${styleText('red', targetVersion)} is not available`);
510
+ }
511
+
512
+ if (versions.installed === targetVersion) {
513
+ Logger.printSuccess(`${styleText('green', name)} is already at version ${styleText('green', targetVersion)}`);
514
+ return;
515
+ }
516
+
517
+ await updateK8sVersion({ ownerId, clusterId }, { targetVersion }).then(sendToApi);
518
+ Logger.printSuccess(`${styleText('green', name)} is upgrading to ${styleText('green', targetVersion)}…`);
519
+ }
520
+
521
+ function getClusterDisplayName(clusterIdOrName, fallbackId) {
522
+ if (typeof clusterIdOrName === 'object') {
523
+ return clusterIdOrName.addon_name ?? clusterIdOrName.operator_id ?? fallbackId;
524
+ }
525
+ return clusterIdOrName ?? fallbackId;
526
+ }
@@ -198,6 +198,19 @@ export async function operatorOpenWebUi(provider, addonIdOrName) {
198
198
  await openBrowser(operator.accessUrl, `Opening ${styleText('blue', operator.addonId)} web UI in the browser…`);
199
199
  }
200
200
 
201
+ /**
202
+ * Open an Otoroshi Swagger UI in the browser
203
+ * @param {{ addon_name?: string, operator_id?: string, addon_id?: string }} addonIdOrName The operator's name or ID
204
+ * @returns {Promise<void>}
205
+ */
206
+ export async function operatorOpenSwaggerUi(addonIdOrName) {
207
+ const operator = await Operator.getDetails('otoroshi', addonIdOrName);
208
+ await openBrowser(
209
+ operator.api.swaggerUrl,
210
+ `Opening ${styleText('blue', operator.addonId)} Swagger UI in the browser…`,
211
+ );
212
+ }
213
+
201
214
  /**
202
215
  * Reboot an operator
203
216
  * @param {object} params The command's parameters
@@ -257,7 +270,8 @@ export async function operatorPrint(provider, addonIdOrName, format = 'human') {
257
270
  dataToPrint['Access URL'] = operator.accessUrl;
258
271
 
259
272
  if (provider === 'otoroshi') {
260
- dataToPrint['API URL'] = operator.api.url;
273
+ dataToPrint['Swagger URL'] = operator.api.swaggerUrl;
274
+ dataToPrint['API endpoint'] = operator.api.url;
261
275
  }
262
276
 
263
277
  if (['otoroshi', 'keycloak'].includes(provider)) {
@@ -5,8 +5,8 @@ export function promptSecret(message) {
5
5
  return password({ message, mask: true }).catch(exitOnPromptError);
6
6
  }
7
7
 
8
- export async function confirm(message, rejectionMessage) {
9
- const answer = await confirmPrompt({ message }).catch(exitOnPromptError);
8
+ export async function confirm(message, rejectionMessage, defaultAnswer = true) {
9
+ const answer = await confirmPrompt({ message, default: defaultAnswer }).catch(exitOnPromptError);
10
10
  if (!answer) {
11
11
  throw new Error(rejectionMessage);
12
12
  }
@@ -14,6 +14,10 @@ export async function confirm(message, rejectionMessage) {
14
14
  return answer;
15
15
  }
16
16
 
17
+ export async function ask(message, defaultAnswer = true) {
18
+ return confirmPrompt({ message, default: defaultAnswer }).catch(exitOnPromptError);
19
+ }
20
+
17
21
  export async function confirmAnswer(message, rejectionMessage, expectedAnswer) {
18
22
  const answer = await input({ message }).catch(exitOnPromptError);
19
23
  if (answer !== expectedAnswer) {
package/src/parsers.js CHANGED
@@ -86,6 +86,16 @@ export function commaSeparated(string) {
86
86
  return string.split(',');
87
87
  }
88
88
 
89
+ const flavorCountRegex = /^[^:]+:\d+$/;
90
+
91
+ export function flavorCount(string) {
92
+ if (!flavorCountRegex.test(string)) {
93
+ throw new Error('Expected format: <flavor>:<count>');
94
+ }
95
+ const [flavor, count] = string.split(':');
96
+ return { flavor: flavor.toUpperCase(), targetNodeCount: Number(count) };
97
+ }
98
+
89
99
  // /^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/i;
90
100
  const tagRegex = /^[^,\s]+$/;
91
101