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.
- package/package.json +1 -1
- package/src/clever-client/k8s.js +197 -0
- package/src/commands/addon/addon.docs.md +1 -1
- package/src/commands/global.commands.js +34 -0
- package/src/commands/global.options.js +1 -1
- package/src/commands/k8s/k8s.activity.command.js +53 -0
- package/src/commands/k8s/k8s.args.js +6 -0
- package/src/commands/k8s/k8s.create.command.js +87 -3
- package/src/commands/k8s/k8s.delete.command.js +13 -18
- package/src/commands/k8s/k8s.docs.md +264 -1
- package/src/commands/k8s/k8s.get.command.js +65 -4
- package/src/commands/k8s/k8s.nodegroups.command.js +7 -0
- package/src/commands/k8s/k8s.nodegroups.create.command.js +79 -0
- package/src/commands/k8s/k8s.nodegroups.delete.command.js +34 -0
- package/src/commands/k8s/k8s.nodegroups.get.command.js +66 -0
- package/src/commands/k8s/k8s.nodegroups.list.command.js +49 -0
- package/src/commands/k8s/k8s.nodegroups.update.command.js +86 -0
- package/src/commands/k8s/k8s.quota.command.js +77 -0
- package/src/commands/k8s/k8s.update.command.js +72 -0
- package/src/commands/k8s/k8s.version.check.command.js +18 -0
- package/src/commands/k8s/k8s.version.command.js +18 -0
- package/src/commands/k8s/k8s.version.update.command.js +18 -0
- package/src/commands/oauth-consumers/oauth-consumers.docs.md +1 -1
- package/src/commands/otoroshi/otoroshi.docs.md +14 -0
- package/src/commands/otoroshi/otoroshi.open.swaggerui.command.js +13 -0
- package/src/lib/k8s.js +411 -11
- package/src/lib/operator-commands.js +15 -1
- package/src/lib/prompts.js +6 -2
- 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 {
|
|
32
|
-
* @
|
|
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,
|
|
35
|
-
ownerId = await getOwnerIdFromOrgIdOrName(
|
|
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 },
|
|
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
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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['
|
|
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)) {
|
package/src/lib/prompts.js
CHANGED
|
@@ -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
|
|