clever-tools 3.11.0 → 3.12.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.
@@ -0,0 +1,195 @@
1
+ import colors from 'colors/safe.js';
2
+ import * as networkGroup from '../models/ng.js';
3
+
4
+ import { Logger } from '../logger.js';
5
+
6
+ /**
7
+ * Print a Network Group
8
+ * @param {Object} ng The Network Group to print
9
+ * @param {string} format Output format
10
+ * @param {boolean} full If true, get more details about the Network Group (default: false)
11
+ */
12
+ function printNg (ng, format, full = false) {
13
+
14
+ switch (format) {
15
+ case 'json': {
16
+ Logger.printJson(ng);
17
+ break;
18
+ }
19
+ case 'human':
20
+ default: {
21
+ const ngData = {
22
+ ID: ng.id,
23
+ Label: ng.label,
24
+ Description: ng.description,
25
+ Network: `${ng.networkIp}`,
26
+ 'Members/Peers': `${Object.keys(ng.members)?.length}/${Object.keys(ng.peers)?.length}`,
27
+ };
28
+
29
+ console.table(ngData);
30
+
31
+ if (full) {
32
+ const members = Object.entries(ng.members)
33
+ .sort((a, b) => a[1].domainName.localeCompare(b[1].domainName))
34
+ .map(([id, member]) => ({
35
+ Domain: member.domainName,
36
+ }));
37
+ if (members.length > 0) {
38
+ Logger.println(colors.bold(' • Members:'));
39
+ console.table(members);
40
+ }
41
+
42
+ const peers = Object.entries(ng.peers)
43
+ .sort((a, b) => a[1].parentMember.localeCompare(b[1].parentMember))
44
+ .map(([id, peer]) => formatPeer(peer));
45
+ if (peers.length > 0) {
46
+ Logger.println(colors.bold(' • Peers:'));
47
+ console.table(peers);
48
+ }
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Print a Network Group member
56
+ * @param {Object} member The Network Group member to print
57
+ * @param {string} format Output format
58
+ */
59
+ function printMember (member, format) {
60
+
61
+ switch (format) {
62
+ case 'json': {
63
+ Logger.printJson(member);
64
+ break;
65
+ }
66
+ case 'human':
67
+ default: {
68
+ console.table({
69
+ Label: member.label,
70
+ Domain: member.domainName,
71
+ });
72
+ }
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Print a Network Group peer
78
+ * @param {Object} peer The Network Group peer to print
79
+ * @param {string} format Output format
80
+ * @param {boolean} full If true, get more details about the peer (default: false)
81
+ */
82
+ function printPeer (peer, format, full = false) {
83
+ switch (format) {
84
+ case 'json': {
85
+ Logger.printJson(peer);
86
+ break;
87
+ }
88
+ case 'human':
89
+ default: {
90
+ console.table(formatPeer(peer, full));
91
+ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Format a peer to print
97
+ * @param {Object} peer
98
+ * @param {boolean} full If true, get more details about the peer (default: false)
99
+ */
100
+ function formatPeer (peer, full = false) {
101
+ const peerToPrint = {
102
+ 'Parent Member': peer.parentMember,
103
+ ID: peer.id,
104
+ Label: peer.label,
105
+ Type: peer.type,
106
+ };
107
+
108
+ if (full) {
109
+ if (peer.endpoint.ngTerm != null) {
110
+ peerToPrint['Host:IP'] = `${peer.endpoint.ngTerm.host}:${peer.endpoint.ngTerm.port}`;
111
+ }
112
+ else {
113
+ peerToPrint.Host = peer.endpoint.ngIp;
114
+ }
115
+ if (peer.endpoint.publicTerm != null) {
116
+ peerToPrint['Public Term'] = `${peer.endpoint.publicTerm.host}:${peer.endpoint.publicTerm.port}`;
117
+ }
118
+ peerToPrint['Public Key'] = peer.publicKey;
119
+ }
120
+
121
+ return peerToPrint;
122
+ }
123
+
124
+ /** Print the results of a search or get action
125
+ * @param {object} idOrLabel ID or label of the Network Group, a member or a peer
126
+ * @param {object} org Organisation ID or name
127
+ * @param {string} format Output format
128
+ * @param {string} action Action to perform (search or get)
129
+ * @param {string} type Type of item to search (NetworkGroup, Member, Peer)
130
+ */
131
+ export async function printResults (idOrLabel, org, format, action, type) {
132
+
133
+ const exactMatch = action === 'get';
134
+ const toLookFor = type ?? (action === 'search' ? 'all' : 'single');
135
+
136
+ const found = await networkGroup.searchNgOrResource(idOrLabel, org, toLookFor, exactMatch);
137
+
138
+ if (!found.length) {
139
+ const searchString = idOrLabel.ngId
140
+ ?? idOrLabel.memberId
141
+ ?? idOrLabel.ngResourceLabel;
142
+ Logger.println(`${colors.blue('!')} No Network Group or resource found for ${colors.blue(searchString)}`);
143
+ return;
144
+ }
145
+
146
+ if (found.length === 1) {
147
+ switch (found[0].type) {
148
+ case 'NetworkGroup':
149
+ return printNg(found[0], format, true);
150
+ case 'Member':
151
+ return printMember(found[0], format);
152
+ case 'CleverPeer':
153
+ case 'ExternalPeer':
154
+ return printPeer(found[0], format, true);
155
+ default:
156
+ throw new Error(`Unknown item type: ${found[0].type}`);
157
+ }
158
+ }
159
+
160
+ if (action === 'search') {
161
+ // Group found items by type in a new object
162
+ const grouped = found.reduce((acc, item) => {
163
+ if (!acc[item.type]) {
164
+ acc[item.type] = [];
165
+ }
166
+ acc[item.type].push(item);
167
+ return acc;
168
+ }, {});
169
+
170
+ switch (format) {
171
+ case 'json': {
172
+ Logger.printJson(grouped);
173
+ break;
174
+ }
175
+ case 'human':
176
+ default: {
177
+ if (grouped.NetworkGroup) {
178
+ Logger.println(`${colors.bold(` • Found ${grouped.NetworkGroup.length} Network Group(s):`)}`);
179
+ grouped.NetworkGroup?.forEach((item) => printNg(item, format));
180
+ }
181
+
182
+ if (grouped.Member) {
183
+ Logger.println(`${colors.bold(` • Found ${grouped.Member.length} Member(s):`)}`);
184
+ grouped.Member?.forEach((item) => printMember(item, format));
185
+ }
186
+
187
+ if (grouped.ExternalPeer || grouped.CleverPeer) {
188
+ Logger.println(`${colors.bold(` • Found ${grouped.ExternalPeer.length + grouped.CleverPeer.length} Peer(s):`)}`);
189
+ grouped.CleverPeer?.forEach((item) => printPeer(item, format));
190
+ grouped.ExternalPeer?.forEach((item) => printPeer(item, format));
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
package/src/logger.js CHANGED
@@ -55,6 +55,9 @@ export const Logger = _(['debug', 'info', 'warn', 'error'])
55
55
  // No decoration for Logger.println
56
56
  Logger.println = console.log;
57
57
 
58
+ // Logger for success with a green check before the message
59
+ Logger.printSuccess = (message) => console.log(`${colors.bold.green('✓')} ${message}`);
60
+
58
61
  // No decoration for Logger.println
59
62
  Logger.printJson = (obj) => {
60
63
  console.log(JSON.stringify(obj, null, 2));
@@ -1,8 +1,7 @@
1
- import * as application from '@clevercloud/client/esm/api/v2/application.js';
2
-
3
1
  import { sendToApi } from './send-to-api.js';
2
+ import { getAllDeployments } from '@clevercloud/client/esm/api/v2/application.js';
4
3
 
5
4
  export function list (ownerId, appId, showAll) {
6
5
  const limit = showAll ? null : 10;
7
- return application.getAllDeployments({ id: ownerId, appId, limit }).then(sendToApi);
6
+ return getAllDeployments({ id: ownerId, appId, limit }).then(sendToApi);
8
7
  };
@@ -1,4 +1,3 @@
1
- import * as application from '@clevercloud/client/esm/api/v2/application.js';
2
1
  import cliparse from 'cliparse';
3
2
 
4
3
  import { get as getAddon, getAll as getAllAddons, getAllEnvVars, remove as removeAddon, create as createAddon, update as updateAddon } from '@clevercloud/client/esm/api/v2/addon.js';
@@ -10,6 +9,7 @@ import * as Interact from './interact.js';
10
9
  import { Logger } from '../logger.js';
11
10
  import { sendToApi } from '../models/send-to-api.js';
12
11
  import { resolveOwnerId } from './ids-resolver.js';
12
+ import { getAllLinkedAddons, linkAddon, unlinkAddon } from '@clevercloud/client/esm/api/v2/application.js';
13
13
 
14
14
  export function listProviders () {
15
15
  return getAllAddonProviders({}).then(sendToApi);
@@ -42,7 +42,7 @@ export async function list (ownerId, appId, showAll) {
42
42
  return allAddons;
43
43
  }
44
44
 
45
- const myAddons = await application.getAllLinkedAddons({ id: ownerId, appId }).then(sendToApi);
45
+ const myAddons = await getAllLinkedAddons({ id: ownerId, appId }).then(sendToApi);
46
46
 
47
47
  if (showAll == null) {
48
48
  return myAddons;
@@ -191,12 +191,12 @@ async function getId (ownerId, addon) {
191
191
 
192
192
  export async function link (ownerId, appId, addon) {
193
193
  const addonId = await getId(ownerId, addon);
194
- return application.linkAddon({ id: ownerId, appId }, JSON.stringify(addonId)).then(sendToApi);
194
+ return linkAddon({ id: ownerId, appId }, JSON.stringify(addonId)).then(sendToApi);
195
195
  }
196
196
 
197
197
  export async function unlink (ownerId, appId, addon) {
198
198
  const addonId = await getId(ownerId, addon);
199
- return application.unlinkAddon({ id: ownerId, appId, addonId }).then(sendToApi);
199
+ return unlinkAddon({ id: ownerId, appId, addonId }).then(sendToApi);
200
200
  }
201
201
 
202
202
  export async function deleteAddon (ownerId, addonIdOrName, skipConfirmation) {
@@ -1,7 +1,16 @@
1
1
  import _ from 'lodash';
2
- import * as application from '@clevercloud/client/esm/api/v2/application.js';
2
+ import {
3
+ create as createApplication,
4
+ remove as removeApplication,
5
+ getAll as getAllApplications,
6
+ get as getApplication,
7
+ redeploy as redeployApplication,
8
+ update as updateApplication,
9
+ getAllDependencies,
10
+ addDependency,
11
+ removeDependency,
12
+ } from '@clevercloud/client/esm/api/v2/application.js';
3
13
  import cliparse from 'cliparse';
4
- import * as product from '@clevercloud/client/esm/api/v2/product.js';
5
14
  import { getSummary } from '@clevercloud/client/esm/api/v2/user.js';
6
15
 
7
16
  import * as AppConfiguration from './app_configuration.js';
@@ -12,6 +21,7 @@ import * as User from './user.js';
12
21
 
13
22
  import { sendToApi } from '../models/send-to-api.js';
14
23
  import { resolveOwnerId } from './ids-resolver.js';
24
+ import { getAvailableInstances } from '@clevercloud/client/esm/api/v2/product.js';
15
25
 
16
26
  export function listAvailableTypes () {
17
27
  return cliparse.autocomplete.words(['docker', 'elixir', 'go', 'gradle', 'haskell', 'jar', 'maven', 'meteor', 'node', 'php', 'play1', 'play2', 'python', 'ruby', 'rust', 'sbt', 'static-apache', 'war']);
@@ -42,7 +52,7 @@ async function getId (ownerId, dependency) {
42
52
  async function getInstanceType (type) {
43
53
 
44
54
  // TODO: We should be able to use it without {}
45
- const types = await product.getAvailableInstances({}).then(sendToApi);
55
+ const types = await getAvailableInstances({}).then(sendToApi);
46
56
 
47
57
  const enabledTypes = types.filter((t) => t.enabled);
48
58
  const matchingVariants = enabledTypes.filter((t) => t.variant != null && t.variant.slug === type);
@@ -83,7 +93,7 @@ export async function create (name, typeName, region, orgaIdOrName, github, isTa
83
93
  newApp.oauthApp = github;
84
94
  }
85
95
 
86
- return application.create({ id: ownerId }, newApp).then(sendToApi);
96
+ return createApplication({ id: ownerId }, newApp).then(sendToApi);
87
97
  };
88
98
 
89
99
  export async function deleteApp (app, skipConfirmation) {
@@ -97,7 +107,7 @@ export async function deleteApp (app, skipConfirmation) {
97
107
  );
98
108
  }
99
109
 
100
- return application.remove({ id: app.ownerId, appId: app.id }).then(sendToApi);
110
+ return removeApplication({ id: app.ownerId, appId: app.id }).then(sendToApi);
101
111
  };
102
112
 
103
113
  export async function getAllApps (ownerId) {
@@ -123,7 +133,7 @@ export async function getAllApps (ownerId) {
123
133
  };
124
134
 
125
135
  async function getApplicationsForOwner (ownerId) {
126
- const rawApplications = await application.getAll({ id: ownerId }).then(sendToApi);
136
+ const rawApplications = await getAllApplications({ id: ownerId }).then(sendToApi);
127
137
  return rawApplications.map((app) => {
128
138
  return {
129
139
  app_id: app.id,
@@ -150,13 +160,13 @@ function getApplicationByName (apps, name) {
150
160
  };
151
161
 
152
162
  async function getByName (ownerId, name) {
153
- const apps = await application.getAll({ id: ownerId }).then(sendToApi);
163
+ const apps = await getAllApplications({ id: ownerId }).then(sendToApi);
154
164
  return getApplicationByName(apps, name);
155
165
  };
156
166
 
157
167
  export function get (ownerId, appId) {
158
168
  Logger.debug(`Get information for the app: ${appId}`);
159
- return application.get({ id: ownerId, appId }).then(sendToApi);
169
+ return getApplication({ id: ownerId, appId }).then(sendToApi);
160
170
  };
161
171
 
162
172
  function getFromSelf (appId) {
@@ -164,7 +174,7 @@ function getFromSelf (appId) {
164
174
  // /self differs from /organisations only for this one:
165
175
  // it fallbacks to the organisations of which the user
166
176
  // is a member, if it doesn't belong to Personal Space.
167
- return application.get({ appId }).then(sendToApi);
177
+ return getApplication({ appId }).then(sendToApi);
168
178
  };
169
179
 
170
180
  /**
@@ -245,7 +255,7 @@ export function unlinkRepo (alias) {
245
255
  export function redeploy (ownerId, appId, commit, withoutCache) {
246
256
  Logger.debug(`Redeploying the app: ${appId}`);
247
257
  const useCache = (withoutCache) ? 'no' : null;
248
- return application.redeploy({ id: ownerId, appId, commit, useCache }).then(sendToApi);
258
+ return redeployApplication({ id: ownerId, appId, commit, useCache }).then(sendToApi);
249
259
  };
250
260
 
251
261
  export function mergeScalabilityParameters (scalabilityParameters, instance) {
@@ -283,7 +293,7 @@ export function mergeScalabilityParameters (scalabilityParameters, instance) {
283
293
  export async function setScalability (appId, ownerId, scalabilityParameters, buildFlavor) {
284
294
  Logger.info('Scaling the app: ' + appId);
285
295
 
286
- const app = await application.get({ id: ownerId, appId }).then(sendToApi);
296
+ const app = await getApplication({ id: ownerId, appId }).then(sendToApi);
287
297
  const instance = _.cloneDeep(app.instance);
288
298
 
289
299
  instance.minFlavor = instance.minFlavor.name;
@@ -301,17 +311,17 @@ export async function setScalability (appId, ownerId, scalabilityParameters, bui
301
311
  }
302
312
  }
303
313
 
304
- return application.update({ id: ownerId, appId }, newConfig).then(sendToApi);
314
+ return updateApplication({ id: ownerId, appId }, newConfig).then(sendToApi);
305
315
  };
306
316
 
307
317
  export async function listDependencies (ownerId, appId, showAll) {
308
- const applicationDeps = await application.getAllDependencies({ id: ownerId, appId }).then(sendToApi);
318
+ const applicationDeps = await getAllDependencies({ id: ownerId, appId }).then(sendToApi);
309
319
 
310
320
  if (!showAll) {
311
321
  return applicationDeps.map((app) => ({ ...app, isLinked: true }));
312
322
  }
313
323
 
314
- const allApps = await application.getAll({ id: ownerId }).then(sendToApi);
324
+ const allApps = await getAllApplications({ id: ownerId }).then(sendToApi);
315
325
 
316
326
  const applicationDepsIds = applicationDeps.map((app) => app.id);
317
327
  return allApps.map((app) => {
@@ -322,10 +332,10 @@ export async function listDependencies (ownerId, appId, showAll) {
322
332
 
323
333
  export async function link (ownerId, appId, dependency) {
324
334
  const dependencyId = await getId(ownerId, dependency);
325
- return application.addDependency({ id: ownerId, appId, dependencyId }).then(sendToApi);
335
+ return addDependency({ id: ownerId, appId, dependencyId }).then(sendToApi);
326
336
  };
327
337
 
328
338
  export async function unlink (ownerId, appId, dependency) {
329
339
  const dependencyId = await getId(ownerId, dependency);
330
- return application.removeDependency({ id: ownerId, appId, dependencyId }).then(sendToApi);
340
+ return removeDependency({ id: ownerId, appId, dependencyId }).then(sendToApi);
331
341
  };
@@ -123,6 +123,7 @@ export async function setFeature (feature, value) {
123
123
 
124
124
  export const conf = env.getOrElseAll({
125
125
  API_HOST: 'https://api.clever-cloud.com',
126
+ AUTH_BRIDGE_HOST: 'https://api-bridge.clever-cloud.com',
126
127
  SSH_GATEWAY: 'ssh@sshgateway-clevercloud-customers.services.clever-cloud.com',
127
128
 
128
129
  // the disclosure of these tokens is not considered as a vulnerability. Do not report this to our security service.
@@ -1,9 +1,10 @@
1
1
  import * as Application from './application.js';
2
- import * as organisation from '@clevercloud/client/esm/api/v2/organisation.js';
2
+ import { getNamespaces as getTcpRedirNamespaces } from '@clevercloud/client/esm/api/v2/organisation.js';
3
3
  import { sendToApi } from './send-to-api.js';
4
4
  import cliparse from 'cliparse';
5
+
5
6
  export async function getNamespaces (ownerId) {
6
- return organisation.getNamespaces({ id: ownerId }).then(sendToApi);
7
+ return getTcpRedirNamespaces({ id: ownerId }).then(sendToApi);
7
8
  }
8
9
 
9
10
  export async function completeNamespaces () {
@@ -0,0 +1,270 @@
1
+ import colors from 'colors/safe.js';
2
+ import * as networkGroup from './ng.js';
3
+ import * as networkGroupApi from '@clevercloud/client/esm/api/v4/network-group.js';
4
+
5
+ import crypto from 'node:crypto';
6
+ import { setTimeout } from 'node:timers/promises';
7
+ import { Logger } from '../logger.js';
8
+ import { sendToApi } from './send-to-api.js';
9
+ import { getSummary } from '@clevercloud/client/esm/api/v2/user.js';
10
+
11
+ /**
12
+ * Create an external peer and link its parent member to the Network Group
13
+ * @param {object} ngIdOrLabel The Network Group ID or Label
14
+ * @param {string} peerLabel External peer label
15
+ * @param {string} publicKey External peer public key
16
+ * @param {object} org Organisation ID or name
17
+ * @throws {Error} If a valid peer label is not provided
18
+ * @throws {Error} If the Network Group is not found
19
+ * @throws {Error} If the parent member is not linked to the Network Group
20
+ * @throws {Error} If the external peer is not linked to the Network Group
21
+ */
22
+ export async function createExternalPeerWithParent (ngIdOrLabel, peerLabel, publicKey, org) {
23
+
24
+ if (!peerLabel) {
25
+ throw new Error('A valid peer label is required');
26
+ }
27
+
28
+ const [ng] = await networkGroup.searchNgOrResource(ngIdOrLabel, org, 'NetworkGroup');
29
+
30
+ if (!ng) {
31
+ throw new Error(`Network Group ${colors.red(ngIdOrLabel.ngId || ngIdOrLabel.ngResourceLabel)} not found`);
32
+ }
33
+
34
+ // We define a parent member for the external peer
35
+ const id = `external_${crypto.randomUUID()}`;
36
+ const parentMember = {
37
+ id,
38
+ label: `Parent of ${peerLabel}`,
39
+ domainName: `${id}.m.${ng.id}.${networkGroup.DOMAIN}`,
40
+ kind: 'EXTERNAL',
41
+ };
42
+
43
+ Logger.info(`Creating a parent member ${parentMember.id} linked to Network Group ${ng.id}`);
44
+ await linkMember({ ngId: ng.id }, parentMember.id, org, parentMember.label);
45
+
46
+ const checkParentMember = await checkResource(ng.id, org, parentMember.id, true);
47
+ if (!checkParentMember) {
48
+ throw new Error(`Parent member ${colors.red(parentMember.id)} not linked to Network Group ${colors.red(ng.id)}`);
49
+ }
50
+
51
+ Logger.info(`Parent member ${parentMember.id} created and linked to Network Group ${ng.id}`);
52
+
53
+ // We define the external peer, for now we only support client role
54
+ const body = {
55
+ peerRole: 'CLIENT',
56
+ publicKey,
57
+ label: peerLabel,
58
+ parentMember: parentMember.id,
59
+ };
60
+
61
+ Logger.info(`Adding external peer to Member ${parentMember.id} of Network Group ${ng.id}`);
62
+ Logger.debug('Sending body: ' + JSON.stringify(body, null, 2));
63
+ await networkGroupApi.createNetworkGroupExternalPeer({ ownerId: ng.ownerId, networkGroupId: ng.id }, body).then(sendToApi);
64
+
65
+ const checkExternalPeer = await checkResource(ng.id, org, peerLabel, true, 'peer', 'label');
66
+ if (!checkExternalPeer) {
67
+ throw new Error(`External peer ${colors.red(peerLabel)} not linked to Network Group ${colors.red(ng.id)}`);
68
+ }
69
+
70
+ Logger.info(`External peer ${peerLabel} added to Member ${parentMember.id} of Network Group ${ng.id}`);
71
+ }
72
+
73
+ /**
74
+ * Delete an external peer and its parent member from a Network Group
75
+ * @param {object} ngIdOrLabel Network Group ID or label
76
+ * @param {string} peerIdOrLabel External peer ID or label
77
+ * @param {object} org Organisation ID or name
78
+ * @throws {Error} If the Network Group is not found
79
+ * @throws {Error} If the External Peer is not found
80
+ * @throws {Error} If the External Peer is still linked to the Network Group
81
+ * @throws {Error} If the Parent Member is still linked to the Network Group
82
+ */
83
+ export async function deleteExternalPeerWithParent (ngIdOrLabel, peerIdOrLabel, org) {
84
+
85
+ const [ng] = await networkGroup.searchNgOrResource(ngIdOrLabel, org, 'NetworkGroup');
86
+
87
+ if (!ng) {
88
+ throw new Error(`Network Group ${colors.red(ngIdOrLabel.ngId || ngIdOrLabel.ngResourceLabel)} not found`);
89
+ }
90
+
91
+ const externalPeer = ng.peers.find((p) => {
92
+ return p.id === peerIdOrLabel || p.label === peerIdOrLabel;
93
+ });
94
+
95
+ if (!externalPeer) {
96
+ throw new Error(`External peer ${colors.red(peerIdOrLabel)} not found`);
97
+ }
98
+
99
+ Logger.info(`Deleting external peer ${externalPeer.id} from Network Group ${ng.id}`);
100
+ await networkGroupApi.deleteNetworkGroupExternalPeer({ ownerId: ng.ownerId, networkGroupId: ng.id, peerId: externalPeer.id }).then(sendToApi);
101
+
102
+ const checkPeer = await checkResource(ng.id, org, externalPeer.id, false, 'peer');
103
+ if (!checkPeer) {
104
+ throw new Error(`External peer ${colors.red(externalPeer.id)} still linked to Network Group ${colors.red(ng.id)}`);
105
+ }
106
+
107
+ Logger.info(`External peer ${externalPeer.id} deleted from Network Group ${ng.id}`);
108
+ Logger.info(`Unlinking parent member ${externalPeer.parentMember} from Network Group ${ng.id}`);
109
+
110
+ await unlinkMember(ngIdOrLabel, externalPeer.parentMember, org);
111
+
112
+ const checkParentMember = await checkResource(ng.id, org, externalPeer.parentMember, false);
113
+ if (!checkParentMember) {
114
+ throw new Error(`Parent member ${colors.red(externalPeer.parentMember)} still linked to Network Group ${colors.red(ng.id)}`);
115
+ }
116
+
117
+ Logger.info(`Parent member ${externalPeer.parentMember} unlinked from Network Group ${ng.id}`);
118
+ }
119
+
120
+ /**
121
+ * Link a Member to a Network Group
122
+ * @param {object} ngIdOrLabel The Network group ID or Label
123
+ * @param {string} memberId ID of the Member to link
124
+ * @param {object} org Organisation ID or name
125
+ * @param {string} label Label of the Member
126
+ */
127
+ export async function linkMember (ngIdOrLabel, memberId, org, label) {
128
+ if (!memberId) {
129
+ throw new Error('A valid member ID is required (addon_xxx, app_xxx, external_xxx)');
130
+ }
131
+
132
+ const [ng] = await networkGroup.searchNgOrResource(ngIdOrLabel, org, 'NetworkGroup');
133
+
134
+ if (!ng) {
135
+ throw new Error(`Network Group ${colors.red(ngIdOrLabel.ngId || ngIdOrLabel.ngResourceLabel)} not found`);
136
+ }
137
+
138
+ await checkMembersToLink([memberId], ng.ownerId);
139
+
140
+ const alreadyMember = ng.members.find((m) => m.id === memberId);
141
+ if (alreadyMember) {
142
+ throw new Error(`Member ${colors.red(memberId)} is already linked to Network Group ${colors.red(ng.id)}`);
143
+ }
144
+
145
+ const [member] = networkGroup.constructMembers(ng.id, [memberId]);
146
+
147
+ const body = {
148
+ id: member.id,
149
+ label: label || member.label,
150
+ domainName: member.domainName,
151
+ kind: member.kind,
152
+ };
153
+
154
+ Logger.info(`Linking member ${member.id} to Network Group ${ng.id}`);
155
+ Logger.debug('Sending body: ' + JSON.stringify(body, null, 2));
156
+ await networkGroupApi.createNetworkGroupMember({ ownerId: ng.ownerId, networkGroupId: ng.id }, body).then(sendToApi);
157
+
158
+ const check = await checkResource(ng.id, org, member.id, true);
159
+ if (!check) {
160
+ throw new Error(`Member ${colors.red(member.id)} not linked to Network Group ${colors.red(ng.id)}`);
161
+ }
162
+
163
+ Logger.info(`Member ${member.id} linked to Network Group ${ng.id}`);
164
+ }
165
+
166
+ /**
167
+ * Unlink a Member from a Network Group
168
+ * @param {object} ngIdOrLabel The Network Group ID or Label
169
+ * @param {string} memberId The Member ID
170
+ * @param {object} org Organisation ID or name
171
+ * @throws {Error} If a valid member ID is not provided
172
+ * @throws {Error} If the Network Group is not found
173
+ * @throws {Error} If the Member is not found in the Network Group
174
+ * @throws {Error} If the Member is still linked to the Network Group
175
+ */
176
+ export async function unlinkMember (ngIdOrLabel, memberId, org) {
177
+ if (!memberId) {
178
+ throw new Error('A valid member ID is required (addon_xxx, app_xxx, external_xxx)');
179
+ }
180
+
181
+ const [ng] = await networkGroup.searchNgOrResource(ngIdOrLabel, org, 'NetworkGroup');
182
+
183
+ if (!ng) {
184
+ throw new Error(`Network Group ${colors.red(ngIdOrLabel.ngId || ngIdOrLabel.ngLabel)} not found`);
185
+ }
186
+
187
+ const member = ng.members.find((m) => m.id === memberId);
188
+ if (!member) {
189
+ throw new Error(`Member ${colors.red(memberId)} not found in Network Group ${colors.red(ng.id)}`);
190
+ }
191
+
192
+ Logger.info(`Unlinking member ${memberId} from Network Group ${ng.id}`);
193
+ await networkGroupApi.deleteNetworkGroupMember({ ownerId: ng.ownerId, networkGroupId: ng.id, memberId }).then(sendToApi);
194
+
195
+ const check = await checkResource(ng.id, org, memberId, false);
196
+ if (!check) {
197
+ throw new Error(`Member ${colors.red(memberId)} still linked to Network Group ${colors.red(ng.id)}`);
198
+ }
199
+
200
+ Logger.info(`Member ${memberId} unlinked from Network Group ${ng.id}`);
201
+ }
202
+
203
+ /**
204
+ * Check if members can be linked to a Network Group
205
+ * @param {Array<string>} members Members to check
206
+ * @throws {Error} If members can't be linked to a Network Group
207
+ */
208
+ export async function checkMembersToLink (members, ownerId) {
209
+ const VALID_ADDON_PROVIDERS = [
210
+ 'es-addon',
211
+ 'mongodb-addon',
212
+ 'mysql-addon',
213
+ 'postgresql-addon',
214
+ 'redis-addon',
215
+ ];
216
+
217
+ const summary = await getSummary().then(sendToApi);
218
+
219
+ let data = summary.user;
220
+ if (summary.user.id !== ownerId) {
221
+ data = summary.organisations.find((o) => o.id === ownerId);
222
+ }
223
+
224
+ const membersNotOK = [];
225
+ let source = data.applications;
226
+
227
+ for (const memberId of members) {
228
+ if (memberId.startsWith('addon_')) source = data.addons;
229
+
230
+ const foundRessource = source.find((r) => r.id === memberId);
231
+
232
+ if (foundRessource && memberId.startsWith('addon_') && !VALID_ADDON_PROVIDERS.includes(foundRessource.providerId)) {
233
+ membersNotOK.push(memberId);
234
+ }
235
+ else if (!foundRessource && !memberId.startsWith('external_')) {
236
+ membersNotOK.push(memberId);
237
+ }
238
+ }
239
+
240
+ if (membersNotOK.length > 0) {
241
+ throw new Error(`Member(s) ${colors.red(membersNotOK.join(', '))} can't be linked to the Network Group, check Organisation ID or name`);
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Check if a resource is present in a Network Group by ID or label
247
+ * @param {string} ngId Network Group ID
248
+ * @param {object} org Organisation ID or name
249
+ * @param {string} resource Resource ID or label
250
+ * @param {boolean} shouldBePresent Expected presence of the resource
251
+ * @param {string} [resourceType] Resource type (member or peer), default is member
252
+ * @param {string} [searchBy] Search by 'id' or 'label', default is 'id'
253
+ * @returns {Promise<boolean>} True if the resource is present, false otherwise
254
+ */
255
+ async function checkResource (ngId, org, resource, shouldBePresent, resourceType = 'member', searchBy = 'id') {
256
+ const endTime = Date.now() + networkGroup.POLLING_TIMEOUT_MS;
257
+
258
+ while (Date.now() < endTime) {
259
+ const ng = await networkGroup.getNG(ngId, org);
260
+ const items = resourceType === 'member' ? ng.members : ng.peers;
261
+ const isPresent = items.some((item) => item[searchBy] === resource);
262
+
263
+ if (isPresent === shouldBePresent) {
264
+ return true;
265
+ }
266
+
267
+ await setTimeout(networkGroup.POLLING_INTERVAL_MS);
268
+ }
269
+ return false;
270
+ }