clever-tools 3.10.1 → 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.
@@ -11,13 +11,22 @@ const env = commonEnv(Logger);
11
11
  const CONFIG_FILES = {
12
12
  MAIN: 'clever-tools.json',
13
13
  IDS_CACHE: 'ids-cache.json',
14
+ EXPERIMENTAL_FEATURES_FILE: 'clever-tools-experimental-features.json',
14
15
  };
15
16
 
16
- function getConfigPath (configFile) {
17
- const configDir = (process.platform === 'win32')
17
+ function getConfigDir () {
18
+ return (process.platform === 'win32')
18
19
  ? path.resolve(process.env.APPDATA, 'clever-cloud')
19
20
  : xdg.basedir.configPath('clever-cloud');
20
- return path.resolve(configDir, configFile);
21
+ }
22
+
23
+ function getConfigPath (configFile) {
24
+ return path.resolve(getConfigDir(), configFile);
25
+ }
26
+
27
+ // Every function which need 'clever-cloud' directory, need to call it before
28
+ async function ensureConfigDirExists () {
29
+ await mkdirp(getConfigDir(), { mode: 0o700 });
21
30
  }
22
31
 
23
32
  export async function loadOAuthConf () {
@@ -49,9 +58,8 @@ export async function loadOAuthConf () {
49
58
 
50
59
  export async function writeOAuthConf (oauthData) {
51
60
  Logger.debug('Write the tokens in the configuration file…');
52
- const configDir = path.dirname(conf.CONFIGURATION_FILE);
53
61
  try {
54
- await mkdirp(configDir, { mode: 0o700 });
62
+ await ensureConfigDirExists();
55
63
  await fs.writeFile(conf.CONFIGURATION_FILE, JSON.stringify(oauthData));
56
64
  }
57
65
  catch (error) {
@@ -78,6 +86,7 @@ export async function writeIdsCache (ids) {
78
86
  const cachePath = getConfigPath(CONFIG_FILES.IDS_CACHE);
79
87
  const idsJson = JSON.stringify(ids);
80
88
  try {
89
+ await ensureConfigDirExists();
81
90
  await fs.writeFile(cachePath, idsJson);
82
91
  }
83
92
  catch (error) {
@@ -85,22 +94,49 @@ export async function writeIdsCache (ids) {
85
94
  }
86
95
  }
87
96
 
97
+ export async function getFeatures () {
98
+ Logger.debug('Get features configuration from ' + conf.EXPERIMENTAL_FEATURES_FILE);
99
+ try {
100
+ const rawFile = await fs.readFile(conf.EXPERIMENTAL_FEATURES_FILE);
101
+ return JSON.parse(rawFile);
102
+ }
103
+ catch (error) {
104
+ if (error.code !== 'ENOENT') {
105
+ throw new Error(`Cannot get experimental features configuration from ${conf.EXPERIMENTAL_FEATURES_FILE}`);
106
+ }
107
+ return {};
108
+ }
109
+ }
110
+
111
+ export async function setFeature (feature, value) {
112
+ const currentFeatures = await getFeatures();
113
+ const newFeatures = { ...currentFeatures, ...{ [feature]: value } };
114
+
115
+ try {
116
+ await ensureConfigDirExists();
117
+ await fs.writeFile(conf.EXPERIMENTAL_FEATURES_FILE, JSON.stringify(newFeatures, null, 2));
118
+ }
119
+ catch (error) {
120
+ throw new Error(`Cannot write experimental features configuration to ${conf.EXPERIMENTAL_FEATURES_FILE}`);
121
+ }
122
+ }
123
+
88
124
  export const conf = env.getOrElseAll({
89
125
  API_HOST: 'https://api.clever-cloud.com',
90
- // API_HOST: 'https://ccapi-preprod.cleverapps.io',
91
- LOG_WS_URL: 'wss://api.clever-cloud.com/v2/logs/logs-socket/<%- appId %>?since=<%- timestamp %>',
92
- LOG_HTTP_URL: 'https://api.clever-cloud.com/v2/logs/<%- appId %>',
93
- EVENT_URL: 'wss://api.clever-cloud.com/v2/events/event-socket',
94
- WARP_10_EXEC_URL: 'https://c1-warp10-clevercloud-customers.services.clever-cloud.com/api/v0/exec',
126
+ AUTH_BRIDGE_HOST: 'https://api-bridge.clever-cloud.com',
127
+ SSH_GATEWAY: 'ssh@sshgateway-clevercloud-customers.services.clever-cloud.com',
128
+
95
129
  // the disclosure of these tokens is not considered as a vulnerability. Do not report this to our security service.
96
130
  OAUTH_CONSUMER_KEY: 'T5nFjKeHH4AIlEveuGhB5S3xg8T19e',
97
131
  OAUTH_CONSUMER_SECRET: 'MgVMqTr6fWlf2M0tkC2MXOnhfqBWDT',
98
- SSH_GATEWAY: 'ssh@sshgateway-clevercloud-customers.services.clever-cloud.com',
99
132
 
133
+ APP_CONFIGURATION_FILE: path.resolve('.', '.clever.json'),
100
134
  CONFIGURATION_FILE: getConfigPath(CONFIG_FILES.MAIN),
101
- CONSOLE_TOKEN_URL: 'https://console.clever-cloud.com/cli-oauth',
102
- // CONSOLE_TOKEN_URL: 'https://next-console.cleverapps.io/cli-oauth',
135
+ EXPERIMENTAL_FEATURES_FILE: getConfigPath(CONFIG_FILES.EXPERIMENTAL_FEATURES_FILE),
103
136
 
104
- CLEVER_CONFIGURATION_DIR: path.resolve('.', 'clevercloud'),
105
- APP_CONFIGURATION_FILE: path.resolve('.', '.clever.json'),
137
+ API_DOC_URL: 'https://www.clever-cloud.com/developers/api',
138
+ DOC_URL: 'https://www.clever-cloud.com/developers/doc',
139
+ CONSOLE_URL: 'https://console.clever-cloud.com',
140
+ CONSOLE_TOKEN_URL: 'https://console.clever-cloud.com/cli-oauth',
141
+ GOTO_URL: 'https://console.clever-cloud.com/goto',
106
142
  });
@@ -1,5 +1,6 @@
1
- import { getSummary } from '@clevercloud/client/esm/api/v2/user.js';
1
+ import { Logger } from '../logger.js';
2
2
  import { sendToApi } from './send-to-api.js';
3
+ import { getSummary } from '@clevercloud/client/esm/api/v2/user.js';
3
4
  import { loadIdsCache, writeIdsCache } from './configuration.js';
4
5
 
5
6
  /*
@@ -102,3 +103,39 @@ async function getIdsFromSummary () {
102
103
 
103
104
  return ids;
104
105
  }
106
+
107
+ /**
108
+ * Get the IDs and owners of found add-ons from a name, ID or real ID
109
+ * @param {string} addonIdOrRealIdOrName
110
+ * @param {{ orga_name?: string, orga_id?: string }} ownerNameOrId
111
+ * @throws {Error} if no add-on is found
112
+ * @throws {Error} if several add-ons are found
113
+ * @returns {Object} The ID and owner ID of the add-on { addonId, ownerId }
114
+ */
115
+ export async function findAddonsByNameOrId (addonIdOrRealIdOrName, ownerNameOrId) {
116
+ const summary = await getSummary().then(sendToApi);
117
+
118
+ Logger.debug(`Searching for add-on '${addonIdOrRealIdOrName}' in ${summary.user.id} and ${summary.organisations.map((org) => org.id).join(', ')}`);
119
+ const candidates = [summary.user, ...summary.organisations]
120
+ .flatMap((owner) => owner.addons.map((addon) => ({ addon, owner })))
121
+ .filter(({ addon, owner }) => {
122
+ const matchOwner = ownerNameOrId == null
123
+ || owner.id === ownerNameOrId.orga_id
124
+ || owner.name === ownerNameOrId.orga_name;
125
+ const matchAddon = addon.name === addonIdOrRealIdOrName
126
+ || addon.realId === addonIdOrRealIdOrName
127
+ || addon.id === addonIdOrRealIdOrName;
128
+ return matchOwner && matchAddon;
129
+ })
130
+ .map(({ addon, owner }) => ({
131
+ addonId: addon.id,
132
+ ownerId: owner.id,
133
+ }));
134
+
135
+ Logger.debug(`Found ${candidates.length} candidate(s):`);
136
+ for (const candidate of candidates) {
137
+ Logger.debug(` - ${candidate.addonId} (${candidate.ownerId})`);
138
+ }
139
+
140
+ return candidates;
141
+ }
@@ -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
+ }