diff --git a/bin/clever.js b/bin/clever.js index 557a0b0..177484d 100755 --- a/bin/clever.js +++ b/bin/clever.js @@ -38,12 +38,14 @@ import * as domain from '../src/commands/domain.js'; import * as drain from '../src/commands/drain.js'; import * as env from '../src/commands/env.js'; import * as features from '../src/commands/features.js'; +import * as functions from '../src/commands/functions.js'; import * as kv from '../src/commands/kv.js'; import * as link from '../src/commands/link.js'; import * as login from '../src/commands/login.js'; import * as logout from '../src/commands/logout.js'; import * as logs from '../src/commands/logs.js'; import * as makeDefault from '../src/commands/makeDefault.js'; +import * as ng from '../src/commands/ng.js'; import * as notifyEmail from '../src/commands/notify-email.js'; import * as open from '../src/commands/open.js'; import * as consoleModule from '../src/commands/console.js'; @@ -90,10 +92,49 @@ async function run () { // ARGUMENTS const args = { - kvRawCommand: cliparse.argument('command', { description: 'The raw command to send to the Materia KV or Redis® add-on' }), + faasId: cliparse.argument('faas-id', { + description: 'Function ID', + }), + faasFile: cliparse.argument('filename', { + description: 'Path to the function code', + }), + kvRawCommand: cliparse.argument('command', { + description: 'The raw command to send to the Materia KV or Redis® add-on', + }), kvIdOrName: cliparse.argument('kv-id', { description: 'Add-on/Real ID (or name, if unambiguous) of a Materia KV or Redis® add-on', }), + ngId: cliparse.argument('id', { + description: 'Network Group ID', + parser: Parsers.ngResourceType, + }), + ngLabel: cliparse.argument('ng-label', { + description: 'Network Group label', + parser: Parsers.ngResourceType, + }), + ngIdOrLabel: cliparse.argument('ng-id-or-label', { + description: 'Network Group ID or label', + parser: Parsers.ngResourceType, + }), + ngDescription: cliparse.argument('ng-description', { + description: 'Network Group description', + }), + ngExternalPeerLabel: cliparse.argument('external-peer-label', { + description: 'External peer label', + parser: Parsers.ngResourceType, + }), + ngExternalIdOrLabel: cliparse.argument('external-peer-id-or-label', { + description: 'External peer ID or label', + parser: Parsers.ngResourceType, + }), + ngAnyIdOrLabel: cliparse.argument('id-or-label', { + description: 'ID or Label of a Network group, a member or an (external) peer', + parser: Parsers.ngResourceType, + }), + wgPublicKey: cliparse.argument('public-key', { + metavar: 'public_key', + description: 'Wireguard public key of the external peer to link to a Network Group', + }), addonIdOrName: cliparse.argument('addon-id', { description: 'Add-on ID (or name, if unambiguous)', parser: Parsers.addonIdOrName, @@ -147,6 +188,30 @@ async function run () { // OPTIONS const opts = { + // Network Groups options + ngDescription: cliparse.option('description', { + metavar: 'description', + description: 'Network Group description', + }), + ngMembersIdsToLink: cliparse.option('link', { + metavar: 'members_ids', + description: "Comma separated list of members IDs to link to a Network Group ('app_xxx', 'addon_xxx', 'external_xxx')", + parser: Parsers.commaSeparated, + }), + ngMemberLabel: cliparse.option('label', { + required: false, + metavar: 'member_label', + description: 'The member label', + parser: Parsers.ngResourceType, + }), + ngPeerGetConfig: cliparse.flag('config', { + description: 'Get the Wireguard configuration of an external peer', + }), + ngResourceType: cliparse.option('type', { + metavar: 'type', + description: 'Type of resource to look for (NetworkGroup, Member, CleverPeer, ExternalPeer)', + parser: Parsers.ngValidType, + }), sourceableEnvVarsList: cliparse.flag('add-export', { description: 'Display sourceable env variables setting' }), logsFormat: getOutputFormatOption(['json-stream']), activityFormat: getOutputFormatOption(['json-stream']), @@ -709,6 +774,29 @@ async function run () { commands: [enableFeatureCommand, disableFeatureCommand, listFeaturesCommand, infoFeaturesCommand], }, features.list); + // FUNCTIONS COMMANDS + const functionsCreateCommand = cliparse.command('create', { + description: 'Create a Clever Cloud Function', + }, functions.create); + const functionsDeleteCommand = cliparse.command('delete', { + description: 'Delete a Clever Cloud Function', + args: [args.faasId], + }, functions.destroy); + const functionsDeployCommand = cliparse.command('deploy', { + description: 'Deploy a Clever Cloud Function from compatible source code', + args: [args.faasFile, args.faasId], + }, functions.deploy); + const functionsListDeploymentsCommand = cliparse.command('list-deployments', { + description: 'List deployments of a Clever Cloud Function', + args: [args.faasId], + options: [opts.humanJsonOutputFormat], + }, functions.listDeployments); + const functionsCommand = cliparse.command('functions', { + description: 'Manage Clever Cloud Functions', + options: [opts.orgaIdOrName], + commands: [functionsCreateCommand, functionsDeleteCommand, functionsDeployCommand, functionsListDeploymentsCommand], + }, functions.list); + // KV COMMAND const kvRawCommand = cliparse.command('kv', { description: 'Send a raw command to a Materia KV or Redis® add-on', @@ -746,6 +834,60 @@ async function run () { args: [args.alias], }, makeDefault.makeDefault); + // NETWORK GROUP COMMANDS + const ngCreateExternalPeerCommand = cliparse.command('external', { + description: 'Create an external peer in a Network Group', + args: [args.ngExternalPeerLabel, args.ngIdOrLabel, args.wgPublicKey], + }, ng.createExternalPeer); + const ngDeleteExternalPeerCommand = cliparse.command('external', { + description: 'Delete an external peer from a Network Group', + args: [args.ngExternalIdOrLabel, args.ngIdOrLabel], + }, ng.deleteExternalPeer); + const ngCreateCommand = cliparse.command('create', { + description: 'Create a Network Group', + args: [args.ngLabel], + privateOptions: [opts.ngMembersIdsToLink, opts.ngDescription, opts.optTags], + commands: [ngCreateExternalPeerCommand], + }, ng.createNg); + const ngDeleteCommand = cliparse.command('delete', { + description: 'Delete a Network Group', + args: [args.ngIdOrLabel], + commands: [ngDeleteExternalPeerCommand], + }, ng.deleteNg); + const ngLinkCommand = cliparse.command('link', { + description: 'Link an application or a database add-on by its ID to a Network Group', + args: [args.ngAnyIdOrLabel, args.ngIdOrLabel], + }, ng.linkToNg); + const ngUnlinkCommand = cliparse.command('unlink', { + description: 'Unlink an application or a database add-on by its ID from a Network Group', + args: [args.ngAnyIdOrLabel, args.ngIdOrLabel], + }, ng.unlinkFromNg); + const ngGetCommand = cliparse.command('get', { + description: 'Get details about a Network Group, a member or a peer', + args: [args.ngAnyIdOrLabel], + options: [opts.ngResourceType, opts.humanJsonOutputFormat], + }, ng.get); + const ngGetConfigCommand = cliparse.command('get-config', { + description: 'Get the Wireguard configuration of a peer in a Network Group', + args: [args.ngExternalIdOrLabel, args.ngIdOrLabel], + options: [opts.humanJsonOutputFormat], + }, ng.getPeerConfig); + const ngSearchCommand = cliparse.command('search', { + description: 'Search Network Groups, members or peers and get their details', + args: [args.ngAnyIdOrLabel], + options: [opts.ngResourceType, opts.humanJsonOutputFormat], + }, ng.search); + /* const ngJoinCommand = cliparse.command('join', { + description: 'Join a Network Group', + args: [args.ngIdOrLabel], + }, ng.joinNg); */ + const networkGroupsCommand = cliparse.command('ng', { + description: 'List Network Groups', + options: [opts.orgaIdOrName], + privateOptions: [opts.humanJsonOutputFormat], + commands: [ngCreateCommand, ngDeleteCommand, ngLinkCommand, ngUnlinkCommand, ngGetCommand, ngGetConfigCommand, ngSearchCommand], + }, ng.listNg); + // NOTIFY-EMAIL COMMAND const addEmailNotificationCommand = cliparse.command('add', { description: 'Add a new email notification', @@ -981,6 +1123,12 @@ async function run () { if (featuresFromConf.kv) { commands.push(colorizeExperimentalCommand(kvRawCommand, 'kv')); } + if (featuresFromConf.ng) { + commands.push(colorizeExperimentalCommand(networkGroupsCommand, 'ng')); + } + if (featuresFromConf.functions) { + commands.push(colorizeExperimentalCommand(functionsCommand, 'functions')); + } // CLI PARSER const cliParser = cliparse.cli({ diff --git a/docs/README.md b/docs/README.md index 21bad15..1f51d85 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ to ask for new features, enhancements or help us to provide them to our communit You'll find below the first commands to know to connect Clever Tools to your account, get its information and manage some options. Others are developed in dedicated pages: - [Materia KV](/docs/kv.md) +- [Network Groups](/docs/ng.md) - [Applications: configuration](/docs/applications-config.md) - [Applications: management](/docs/applications-management.md) - [Applications: deployment and lifecycle](/docs/applications-deployment-lifecycle.md) diff --git a/docs/networkgroups/commands.md b/docs/networkgroups/commands.md deleted file mode 100644 index e60831e..0000000 --- a/docs/networkgroups/commands.md +++ /dev/null @@ -1,167 +0,0 @@ -# Network Groups CLI commands - -This document is a list of all Network Group-related commands. - -For each command, example call and output are commented right under. - -To improve readability, and to avoid errors, every option value is written inside quotes. - -> **Disclaimer:** This document isn't generated from code, and therefore might **not** be up-to-date. - -## Table Of Contents - -- [Table Of Contents](#table-of-contents) -- [`clever networkgroups` | `clever ng`](#clever-networkgroups--clever-ng) - - [`list`](#list) - - [`create`](#create) - - [`delete`](#delete) - - [`members`](#members) - - [`members list`](#members-list) - - [`members get`](#members-get) - - [`members add`](#members-add) - - [`members remove`](#members-remove) - - [`peers`](#peers) - - [`peers list`](#peers-list) - - [`peers get`](#peers-get) - - [`peers add-external`](#peers-add-external) - - [`peers remove-external`](#peers-remove-external) - -## `clever networkgroups` | `clever ng` - -List Network Groups commands - -### `list` - -List Network Groups with their labels - -| Param | Description | -| ----------------- | ------------------------------------------- | -| `[--verbose, -v]` | Verbose output (default: false) | -| `[--json, -j]` | Show result in JSON format (default: false) | - ---- - -### `create` - -Create a Network Group - -| Param | Description | -| ------------------------------ | ---------------------------------------------- | -| `[--verbose, -v]` | Verbose output (default: false) | -| `--label NG_LABEL` | Network Group label, also used for dns context | -| `--description NG_DESCRIPTION` | Network Group description | -| `[--tags] TAGS` | List of tags separated by a comma | -| `[--json, -j]` | Show result in JSON format (default: false) | - ---- - -### `delete` - -Delete a Network Group - -| Param | Description | -| ----------------- | ------------------------------- | -| `[--verbose, -v]` | Verbose output (default: false) | -| `--ng NG` | Network Group ID or label | - ---- - -### `members` - -List commands for interacting with Network Groups members - -#### `members list` - -List members of a Network Group - -| Param | Description | -| ---------------------- | -------------------------------------------------------------- | -| `[--verbose, -v]` | Verbose output (default: false) | -| `--ng NG` | Network Group ID or label | -| `[--natural-name, -n]` | Show application names or aliases if possible (default: false) | -| `[--json, -j]` | Show result in JSON format (default: false) | - -#### `members get` - -Get a Network Group member details - -| Param | Description | -| --------------------------- | ---------------------------------------------------------------------------------------------------- | -| `[--verbose, -v]` | Verbose output (default: false) | -| `--ng NG` | Network Group ID or label | -| `--member-id, -m MEMBER_ID` | The member ID: an app ID (i.e. `app_xxx`), add-on ID (i.e. `addon_xxx`) or external node category ID | -| `[--natural-name, -n]` | Show application names or aliases if possible (default: false) | -| `[--json, -j]` | Show result in JSON format (default: false) | - -#### `members add` - -Add an app or addon as a Network Group member - -| Param | Description | -| --------------------------- | ---------------------------------------------------------------------------------------------------- | -| `[--verbose, -v]` | Verbose output (default: false) | -| `--ng NG` | Network Group ID or label | -| `--member-id, -m MEMBER_ID` | The member ID: an app ID (i.e. `app_xxx`), add-on ID (i.e. `addon_xxx`) or external node category ID | -| `--type MEMBER_TYPE` | The member type ('application', 'addon' or 'external') | -| `--domain-name DOMAIN_NAME` | Member name used in the `.m..ng.clever-cloud.com` domain name alias | -| `[--label] MEMBER_LABEL` | The member label | - -#### `members remove` - -Remove an app or addon from a Network Group - -| Param | Description | -| --------------------------- | ---------------------------------------------------------------------------------------------------- | -| `[--verbose, -v]` | Verbose output (default: false) | -| `--ng NG` | Network Group ID or label | -| `--member-id, -m MEMBER_ID` | The member ID: an app ID (i.e. `app_xxx`), add-on ID (i.e. `addon_xxx`) or external node category ID | - ---- - -### `peers` - -List commands for interacting with Network Groups peers - -#### `peers list` - -List peers of a Network Group - -| Param | Description | -| ----------------- | ------------------------------------------- | -| `[--verbose, -v]` | Verbose output (default: false) | -| --ng NG | Network Group ID or label | -| [--json, -j] | Show result in JSON format (default: false) | - -#### `peers get` - -Get a Network Group peer details - -| Param | Description | -| ------------------- | ------------------------------------------- | -| `[--verbose, -v]` | Verbose output (default: false) | -| `--ng NG` | Network Group ID or label | -| `--peer-id PEER_ID` | The peer ID | -| `[--json, -j]` | Show result in JSON format (default: false) | - -#### `peers add-external` - -Add an external node as a Network Group peer - -| Param | Description | -| ------------------------- | ------------------------------------------------- | -| `[--verbose, -v]` | Verbose output (default: false) | -| `--ng NG` | Network Group ID or label | -| `--role PEER_ROLE` | The peer role, ('client' or 'server') | -| `--public-key PUBLIC_KEY` | A WireGuard® public key | -| `--label PEER_LABEL` | Network Group peer label | -| `--parent MEMBER_ID` | Network Group peer category ID (parent member ID) | - -#### `peers remove-external` - -Remove an external node from a Network Group - -| Param | Description | -| ------------------- | ------------------------------- | -| `[--verbose, -v]` | Verbose output (default: false) | -| `--ng NG` | Network Group ID or label | -| `--peer-id PEER_ID` | The peer ID | diff --git a/docs/networkgroups/tests.md b/docs/networkgroups/tests.md deleted file mode 100644 index 949aa2d..0000000 --- a/docs/networkgroups/tests.md +++ /dev/null @@ -1,310 +0,0 @@ -# Network Groups CLI tests - -This document is a list of manual integration tests for the Network Group-related commands. - -For each command, an example output is commented right under. - -To improve readability, and to avoid errors, every option value is written inside quotes. - -## Setup - -> In the following commands, `cleverr` refers to the local `clever-tools`. -> -> Tip: Add `alias cleverr=~/path/to/clever-tools/bin/clever.js` to your `.bashprofile`, `.zprofile` or whatever. - -First, let's define variables to facilitate command line calls. - -```sh -# Valid cases -ngLabel='temp-test' -testAppId='app_b888f06d-3adb-4cf1-b017-7eac4f096e90' -memberId1='my-member-1' -memberId2='my-member-2' -peerLabel1='my-peer-1' -peerLabel2='my-peer-2' -publicKey1=`wg genkey | wg pubkey` -publicKey2=`wg genkey | wg pubkey` - -# Invalid cases -ngLabelForInvalidCases1='test-1' -ngLabelForInvalidCases2='test-2' -``` - -## Tests - -### Valid cases - -#### Create a Network Group - -1. Setup - - ```sh - cleverr ng create --label "$ngLabel" --description '[Test] Create a Network Group' - # Network Group 'test-dev' was created with the id 'ng_cdf5cc11-4fdf-47cf-8d82-5b89f722450a'. - ``` - -2. Test - - ```sh - cleverr ng list - # Network Group ID Label Members Peers Description - # ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - # ng_cdf5cc11-4fdf-47cf-8d82-5b89f722450a test-dev 0 0 [Test] Create a Network Group. - ``` - -3. Tear down - - ```sh - cleverr ng delete --ng "$ngLabel" - # Network Group 'ng_cdf5cc11-4fdf-47cf-8d82-5b89f722450a' was successfully deleted. - ``` - -#### Delete a Network Group - -1. Setup - - ```sh - cleverr ng create --label "$ngLabel" --description '[Test] Delete a Network Group' - # Network Group 'test-dev' was created with the id 'ng_cdf5cc11-4fdf-47cf-8d82-5b89f722450a'. - cleverr ng delete --ng "$ngLabel" - # Network Group 'ng_cdf5cc11-4fdf-47cf-8d82-5b89f722450a' was successfully deleted. - ``` - -2. Test - - ```sh - cleverr ng list - # No Network Group found. - ``` - -#### Add a member - -1. Setup - - ```sh - cleverr ng create --label "$ngLabel" --description '[Test] Add a member' - # Network Group 'test-dev' was created with the id 'ng_cdf5cc11-4fdf-47cf-8d82-5b89f722450a'. - ``` - -2. Test - - ```sh - cleverr ng --ng "$ngLabel" members list - # No member found - cleverr ng --ng "$ngLabel" members add --member-id "$testAppId" --type 'application' --domain-name 'api-tester' --label '[Test] API Tester' - # Successfully added member 'app_b888f06d-3adb-4cf1-b017-7eac4f096e90' to Network Group 'ng_cdf5cc11-4fdf-47cf-8d82-5b89f722450a'. - cleverr ng --ng "$ngLabel" members list - # Member ID Member Type Label Domain Name - # ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - # app_b888f06d-3adb-4cf1-b017-7eac4f096e90 application [Test] API Tester api-tester - ``` - -3. Tear down - - ```sh - cleverr ng delete --ng "$ngLabel" - # Network Group 'ng_cdf5cc11-4fdf-47cf-8d82-5b89f722450a' was successfully deleted. - ``` - -#### Add an external peer - -1. Setup - - ```sh - cleverr ng create --label "$ngLabel" --description '[Test] Add an external peer' - # Network Group 'temp-test' was created with the id 'ng_84c65dce-4a48-4858-b327-83bddf5f0a79'. - cleverr ng --ng "$ngLabel" members add --member-id "$memberId1" --type 'external' --domain-name 'my-nodes-category' --label '[Test] My external nodes category' - # Successfully added member 'my-member-1' to Network Group 'ng_84c65dce-4a48-4858-b327-83bddf5f0a79'. - ``` - -2. Test - - ```sh - cleverr ng --ng "$ngLabel" peers list - # No peer found. You can add an external one with `clever networkgroups peers add-external`. - cleverr ng --ng "$ngLabel" peers add-external --role 'client' --public-key "$publicKey1" --label "$peerLabel1" --parent "$memberId1" - # External peer 'external_3b3e82e2-e656-450b-8cc7-b7498d0134f4' must have been added to Network Group 'ng_84c65dce-4a48-4858-b327-83bddf5f0a79'. - cleverr ng --ng "$ngLabel" peers list - # Peer ID Peer Type Endpoint Type Label Hostname IP Address - # ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - # external_3b3e82e2-e656-450b-8cc7-b7498d0134f4 ExternalPeer ClientEndpoint my-peer-1 my-peer-1 10.105.0.5 - ``` - -3. Tear down - - ```sh - cleverr ng delete --ng "$ngLabel" - # Network Group 'ng_84c65dce-4a48-4858-b327-83bddf5f0a79' was successfully deleted. - ``` - -#### WireGuard® configuration updates when adding a peer - -1. Setup - - ```sh - cleverr ng create --label "$ngLabel" --description '[Test] WireGuard® configuration updates when adding a peer' - # Network Group 'temp-test' was created with the id 'ng_620b3482-f286-4189-9931-a8910f2ea706'. - cleverr ng --ng "$ngLabel" members add --member-id "$memberId1" --type 'external' --domain-name 'my-nodes-category' --label '[Test] My external nodes category' - # Successfully added member 'my-member-1' to Network Group 'ng_620b3482-f286-4189-9931-a8910f2ea706'. - cleverr ng --ng "$ngLabel" peers add-external --role 'client' --public-key "$publicKey1" --label "$peerLabel1" --parent "$memberId1" - # External peer 'external_3056ea93-c10d-4175-a91d-b6ed2803ce7c' must have been added to Network Group 'ng_620b3482-f286-4189-9931-a8910f2ea706'. - cleverr ng --ng "$ngLabel" peers list - # Peer ID Peer Type Endpoint Type Label Hostname IP Address - # ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - # external_3056ea93-c10d-4175-a91d-b6ed2803ce7c ExternalPeer ClientEndpoint my-peer-1 my-peer-1 10.105.0.5 - peerId='external_3056ea93-c10d-4175-a91d-b6ed2803ce7c' - ``` - -2. Test - - Call this endpoint: - - ```http - GET /organisations/:ownerId/networkgroups/:ngId/peers/:peerId/wireguard/configuration - ``` - - You should have something like: - - ```text - "CgpbSW50ZXJmYWNlXQpQcml2YXRlS2V5ID0gPCVQcml2YXRlS2V5JT4KQWRkcmVzcyA9IDEwLjEwNS4wLjUvMTYKCgoKCgo=" - ``` - - ```sh - cleverr ng --ng "$ngLabel" peers add-external --role 'client' --public-key "$publicKey2" --label "$peerLabel2" --parent "$memberId1" - # External peer 'external_0839394b-1ddf-49dc-a9a2-09b0437ed59e' must have been added to Network Group 'ng_620b3482-f286-4189-9931-a8910f2ea706'. - ``` - - Call this endpoint again: - - ```http - GET /organisations/:ownerId/networkgroups/:ngId/peers/:peerId/wireguard/configuration - ``` - - You should have something like: - - ```text - "CgpbSW50ZXJmYWNlXQpQcml2YXRlS2V5ID0gPCVQcml2YXRlS2V5JT4KQWRkcmVzcyA9IDEwLjEwNS4wLjUvMTYKCgoKCltQZWVyXQogICAgUHVibGljS2V5ID0gPHB1Yl9rZXlfMj4KICAgIEFsbG93ZWRJUHMgPSAxMC4xMDUuMC42LzMyCiAgICBQZXJzaXN0ZW50S2VlcGFsaXZlID0gMjUKCgoK" - ``` - -3. Tear down - - ```sh - cleverr ng delete --ng "$ngLabel" - # Network Group 'ng_620b3482-f286-4189-9931-a8910f2ea706' was successfully deleted. - ``` - -### Invalid cases - -#### Create two Network Groups with same label - -1. Setup - - ```sh - cleverr ng create --label "$ngLabel" --description '[Test] Create two Network Groups with same label' - # Network Group 'temp-test' was created with the id 'ng_ebee26cf-f1dc-464c-8359-d3a924a3fd97'. - ``` - -2. Test - - ```sh - cleverr ng create --label "$ngLabel" --description '[Test] Should not be created' - # [ERROR] Error from API: 409 Conflict - ``` - -3. Tear down - - ```sh - cleverr ng delete --ng "$ngLabel" - # Network Group 'ng_ebee26cf-f1dc-464c-8359-d3a924a3fd97' was successfully deleted. - ``` - -#### Add invalid member - -> ⚠️ Does not work actually -> TODO: Create issue - -1. Setup - - ```sh - cleverr ng create --label "$ngLabel" --description '[Test] Add invalid member' - # Network Group 'temp-test' was created with the id 'ng_df116d7b-47f3-469b-bee7-ae5792ff92c4'. - ``` - -2. Test - - ```sh - cleverr ng --ng "$ngLabel" members add --member-id '' --type 'external' --domain-name 'invalid' - # [ERROR] Error from API: 404 Not Found - ``` - -3. Tear down - - ```sh - cleverr ng delete --ng "$ngLabel" - # Network Group 'ng_df116d7b-47f3-469b-bee7-ae5792ff92c4' was successfully deleted. - ``` - -#### Add peer with invalid parent - -1. Setup - - ```sh - cleverr ng create --label "$ngLabelForInvalidCases1" --description '[Test] Network Group 1' - # Network Group 'test-1' was created with the id 'ng_cdf5cc11-4fdf-47cf-8d82-5b89f722450a'. - cleverr ng create --label "$ngLabelForInvalidCases2" --description '[Test] Network Group 2' - # Network Group 'test-2' was created with the id 'ng_c977973a-42ce-4f67-b906-4ffc2dcb250c'. - cleverr ng --ng "$ngLabelForInvalidCases2" members add --member-id "$memberId1" --type 'external' --domain-name 'member-2' --label '[Test] Member in other Network Group' - # Successfully added member 'app_b888f06d-3adb-4cf1-b017-7eac4f096e90' to Network Group 'ng_c977973a-42ce-4f67-b906-4ffc2dcb250c'. - ``` - -2. Test - - - Invalid parent `id` - - ```sh - cleverr ng --ng "$ngLabelForInvalidCases1" peers add-external --role 'client' --public-key "$publicKey1" --label '[Test] Invalid parent' --parent 'invalid_id' - # [ERROR] Error from API: 404 Not Found - ``` - - - Parent in another Network Group - - ```sh - cleverr ng --ng "$ngLabelForInvalidCases1" peers add-external --role 'client' --public-key "$publicKey1" --label '[Test] Parent in other Network Group' --parent "$memberId1" - # [ERROR] Error from API: 404 Not Found - ``` - -3. Tear down - - ```sh - cleverr ng delete --ng "$ngLabelForInvalidCases1" - # Network Group 'ng_cdf5cc11-4fdf-47cf-8d82-5b89f722450a' was successfully deleted. - cleverr ng delete --ng "$ngLabelForInvalidCases2" - # Network Group 'ng_c977973a-42ce-4f67-b906-4ffc2dcb250c' was successfully deleted. - ``` - -### Edge cases - -#### Create Network Group after delete - -1. Setup - - ```sh - cleverr ng create --label "$ngLabel" --description '[Test] Should be deleted' - # Network Group 'temp-test' was created with the id 'ng_811d29f5-2d15-44a6-8f73-d16dee8f6316'. - cleverr ng delete --ng "$ngLabel" - # Network Group 'ng_811d29f5-2d15-44a6-8f73-d16dee8f6316' was successfully deleted. - ``` - -2. Test - - ```sh - cleverr ng create --label "$ngLabel" --description '[Test] Create Network Group after delete' - # Network Group 'temp-test' was created with the id 'ng_4e2d4e0e-9a47-4cb7-95a4-d85355b1ccb3'. - ``` - -3. Tear down - - ```sh - cleverr ng delete --ng "$ngLabel" - # Network Group 'ng_4e2d4e0e-9a47-4cb7-95a4-d85355b1ccb3' was successfully deleted. - ``` diff --git a/docs/ng.md b/docs/ng.md new file mode 100644 index 0000000..7bfb076 --- /dev/null +++ b/docs/ng.md @@ -0,0 +1,130 @@ +# Clever Cloud Network Groups + +Network Groups (NG) are a way to create a private secure network between resources inside Clever Cloud infrastructure, using [Wireguard](https://www.wireguard.com/). It's also possible to connect external resources to a Network Group. There are three components to this feature: + +* Network Group: a group of resources that can communicate with each through an encrypted tunnel +* Member: a resource that can be part of a Network Group (`application`, `addon` or `external`) +* Peer: Instance of a resource connected to a Network Group (can be `external`) + +A Network Group is defined by an ID (`ngId`) and a `label`. It can be completed by a `description` and `tags`. + +> [!NOTE] +> Network Groups are currently in public beta testing phase. You only need a Clever Cloud account to use them. + +Tell us what you think of Network Groups and what features you need from it in [the dedicated section of our GitHub Community](https://github.com/CleverCloud/Community/discussions/categories/network-groups). + +## How it works + +When you create a Network Group, a Wireguard configuration is generated with a corresponding [CIDR](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing). Then, you can, for example, add a Clever Cloud application and an associated add-on to the same Network Group. These are members, defined by an `id`, a `label`, a `kind` and a `domain name`. + +When an application connects to a Network Group, you can reach it on any port inside a NG through its domain name. Any instance of this application is a peer, you can reach independently through an IP (from the attributed CIDR). It works the same way for add-ons and external resources. During alpha testing phase, only applications are supported. + +> [!TIP] +> A Network Group member domain name is composed this way: `.m..ng-cc.cloud` + +## Prerequisites + +To use Network Groups, you need [an alpha release of Clever Tools](https://github.com/CleverCloud/clever-tools/pull/780). + +Activate `ng` feature flag to manage Network Groups: + +``` +clever features enable ng +``` + +Then, check it works with the following command: + +``` +clever ng +``` + +In all the following examples, you can target a specific organization with the `--org` or `-o` option. + +## Create a Network Group + +A Network Group is simple to create: + +``` +clever ng create myNG +``` + +You can create it declaring its members: + +``` +clever ng create myNG --link app_xxx,addon_xxx +``` + +You can add a description and tags: + +``` +clever ng create myNG --description "My first NG" --tags test,ng +``` + +## Delete Network Groups + +You can delete a Network Group through its ID or label: + +``` +clever ng delete ngId +clever ng delete ngLabel +``` + +## List Network Groups + +Once created, you can list your Network Groups: + +``` +clever ng + +┌─────────┬───────-┬─────────-─┬───────────────┬─────────┬───────┐ +| (index) │ ID │ Label │ Network CIDR │ Members │ Peers │ +├─────────┼────────┼───────────┼───────────────┼─────────┼───────┤ +│ 0 │ 'ngId' │ 'ngLabel' │ '10.x.y.z/16' │ X │ Y │ +└─────────┴────────┴──────────-┴───────────────┴─────────┴───────┘ +``` + +A `json` formatted output is available with the `--format/-F json` option. + +## (Un)Link a resource to a Network Group + +To (un)link an application, add-on or external peer to a Network Group: + +``` +clever ng members link app_xxx ngIdOrLabel +clever ng members unlink addon_xxx ngIdorLabel +``` + +## Get information of a Network Group, a member or a peer + +To get information about a network group or a resource (a `json` formatted output is available): + +``` +clever ng get ngIdOrLabel -F json +clever ng get resourceIdOrName +``` + +You can also search for network groups, members or peers: + +``` +clever ng search text_to_search -F json +``` + +> [!NOTE] +> The search command is case-insensitive and will return all resources containing the search string +> The get command look for an exact match and will return an error if multiple resources are found + +## Get the Wireguard configuration of a Peer + +To get the Wireguard configuration of a peer (a `json` formatted output is available): + +``` +clever ng get-config peerIdOrLabel myNG +``` + +## Demos & examples + +You can find ready to deploy projects using Network Groups in the following repositories: + +- XXX + +Create your own and [let us know](https://github.com/CleverCloud/Community/discussions/categories/network-groups)! diff --git a/src/commands/functions.js b/src/commands/functions.js new file mode 100644 index 0000000..0d2f151 --- /dev/null +++ b/src/commands/functions.js @@ -0,0 +1,215 @@ +import fs from 'node:fs'; +import colors from 'colors/safe.js'; + +import * as User from '../models/user.js'; +import * as Organisation from '../models/organisation.js'; + +import { Logger } from '../logger.js'; +import { setTimeout } from 'timers/promises'; +import { sendToApi } from '../models/send-to-api.js'; +import { uploadFunction } from '../models/functions.js'; +import { createFunction, createDeployment, getDeployments, getDeployment, getFunctions, deleteDeployment, triggerDeployment, deleteFunction } from '../models/functions-api.js'; + +const DEFAULT_MAX_INSTANCES = 1; +const DEFAULT_MAX_MEMORY = 64 * 1024 * 1024; + +/** + * Creates a new function + * @param {Object} params + * @param {Object} params.options + * @param {Object} params.options.org - The organisation to create the function in + * @returns {Promise} + * */ +export async function create (params) { + const { org } = params.options; + + const ownerId = (org != null && org.orga_name !== '') + ? await Organisation.getId(org) + : (await User.getCurrent()).id; + + const createdFunction = await createFunction({ ownerId }, { + name: null, + description: null, + environment: {}, + tag: null, + maxInstances: DEFAULT_MAX_INSTANCES, + maxMemory: DEFAULT_MAX_MEMORY, + }).then(sendToApi); + + Logger.println(`${colors.green('✓')} Function ${colors.green(createdFunction.id)} successfully created!`); +} + +/** + * Deploys a function + * @param {Object} params + * @param {Object} params.args + * @param {string} params.args[0] - The file to deploy + * @param {string} params.args[1] - The function ID to deploy to + * @param {Object} params.options + * @param {Object} params.options.org - The organisation to deploy the function to + * @returns {Promise} + * @throws {Error} - If the file to deploy does not exist + * @throws {Error} - If the function to deploy to does not exist + * */ +export async function deploy (params) { + const [functionFile, functionId] = params.args; + const { org } = params.options; + + const ownerId = (org != null && org.orga_name !== '') + ? await Organisation.getId(org) + : (await User.getCurrent()).id; + + if (!fs.existsSync(functionFile)) { + throw new Error(`File ${colors.red(functionFile)} does not exist, it can't be deployed`); + } + + const functions = await getFunctions({ ownerId }).then(sendToApi); + const functionToDeploy = functions.find((f) => f.id === functionId); + + if (!functionToDeploy) { + throw new Error(`Function ${colors.red(functionId)} not found, it can't be deployed`); + } + + Logger.info(`Deploying ${functionFile}`); + Logger.info(`Deploying to function ${functionId} of user ${ownerId}`); + + let deployment = await createDeployment({ + ownerId, + functionId, + }, { + name: null, + description: null, + tag: null, + platform: 'JAVA_SCRIPT', + }).then(sendToApi); + + await uploadFunction(deployment.uploadUrl, functionFile); + + await triggerDeployment({ + ownerId, + functionId, + deploymentId: deployment.id, + }).then(sendToApi); + + Logger.println(`${colors.green('✓')} Function compiled and uploaded successfully!`); + + await setTimeout(1_000); + while (deployment.status !== 'READY') { + deployment = await getDeployment({ + ownerId, + functionId, + deploymentId: deployment.id, + }).then(sendToApi); + await setTimeout(1_000); + } + + Logger.println(`${colors.green('✓')} Your function is now deployed!`); + Logger.println(` └─ Test it: ${colors.blue(`curl https://functions-technical-preview.services.clever-cloud.com/${functionId}`)}`); +} + +/** + * Destroys a function and its deployments + * @param {Object} params + * @param {Object} params.args + * @param {string} params.args[0] - The function ID to destroy + * @param {Object} params.options + * @param {Object} params.options.org - The organisation to destroy the function from + * @returns {Promise} + * @throws {Error} - If the function to destroy does not exist + * */ +export async function destroy (params) { + const [functionId] = params.args; + const { org } = params.options; + + const ownerId = (org != null && org.orga_name !== '') + ? await Organisation.getId(org) + : (await User.getCurrent()).id; + + const functions = await getFunctions({ ownerId }).then(sendToApi); + const functionToDelete = functions.find((f) => f.id === functionId); + + if (!functionToDelete) { + throw new Error(`Function ${colors.red(functionId)} not found, it can't be deleted`); + } + + const deployments = await getDeployments({ ownerId, functionId }).then(sendToApi); + + deployments.forEach(async (d) => { + await deleteDeployment({ ownerId, functionId, deploymentId: d.id }).then(sendToApi); + }); + + await deleteFunction({ ownerId, functionId }).then(sendToApi); + Logger.println(`${colors.green('✓')} Function ${colors.green(functionId)} and its deployments successfully deleted!`); +} + +/** + * Lists all the functions of the current user or the current organisation + * @param {Object} params + * @param {Object} params.options + * @param {Object} params.options.org - The organisation to list the functions from + * @param {string} params.options.format - The format to display the functions + * @returns {Promise} + */ +export async function list (params) { + const { org, format } = params.options; + + const ownerId = (org != null && org.orga_name !== '') + ? await Organisation.getId(org) + : (await User.getCurrent()).id; + + const functions = await getFunctions({ + ownerId: ownerId, + }).then(sendToApi); + + if (functions.length < 1) { + Logger.println(`${colors.blue('🔎')} No functions found, create one with ${colors.blue('clever functions create')} command`); + return; + } + + switch (format) { + case 'json': + console.log(JSON.stringify(functions, null, 2)); + break; + case 'human': + default: + console.table(functions, ['id', 'createdAt', 'updatedAt']); + } +} + +/** + * Lists all the deployments of a function + * @param {Object} params + * @param {Object} params.args + * @param {string} params.args[0] - The function ID to list the deployments from + * @param {Object} params.options + * @param {Object} params.options.org - The organisation to list the deployments from + * @param {string} params.options.format - The format to display the deployments + * @returns {Promise} + * */ +export async function listDeployments (params) { + const [functionId] = params.args; + const { org, format } = params.options; + + const ownerId = (org != null && org.orga_name !== '') + ? await Organisation.getId(org) + : (await User.getCurrent()).id; + + const deploymentsList = await getDeployments({ + ownerId: ownerId, functionId, + }).then(sendToApi); + + if (deploymentsList.length < 1) { + Logger.println(`${colors.blue('🔎')} No deployments found for this function`); + return; + } + + switch (format) { + case 'json': + console.log(JSON.stringify(deploymentsList, null, 2)); + break; + case 'human': + default: + console.table(deploymentsList, ['id', 'status', 'createdAt', 'updatedAt']); + console.log(`▶️ You can call your function with ${colors.blue(`curl https://functions-technical-preview.services.clever-cloud.com/${functionId}`)}`); + } +} diff --git a/src/commands/ng-print.js b/src/commands/ng-print.js new file mode 100644 index 0000000..d863520 --- /dev/null +++ b/src/commands/ng-print.js @@ -0,0 +1,189 @@ +import colors from 'colors/safe.js'; +import { Logger } from '../logger.js'; +import * as NG from '../models/ng.js'; + +/** Print a Network Group + * @param {Object} ng The Network Group to print + * @param {string} format Output format + * @param {boolean} full If true, get more details about the Network Group (default: false) + */ +function printNg (ng, format, full = false) { + + switch (format) { + case 'json': { + Logger.printJson(ng); + break; + } + case 'human': + default: { + const ngData = { + ID: ng.id, + Label: ng.label, + Description: ng.description, + Network: `${ng.networkIp}`, + 'Members/Peers': `${Object.keys(ng.members)?.length}/${Object.keys(ng.peers)?.length}`, + }; + + console.table(ngData); + + if (full) { + const members = Object.entries(ng.members) + .sort((a, b) => a[1].domainName.localeCompare(b[1].domainName)) + .map(([id, member]) => ({ + Domain: member.domainName, + })); + if (members.length) { + Logger.println(`${colors.bold(' • Members:')}`); + console.table(members); + } + + const peers = Object.entries(ng.peers) + .sort((a, b) => a[1].parentMember.localeCompare(b[1].parentMember)) + .map(([id, peer]) => formatPeer(peer)); + if (peers.length) { + Logger.println(`${colors.bold(' • Peers:')}`); + console.table(peers); + } + } + } + } +} + +/** Print a Network Group member + * @param {Object} member The Network Group member to print + * @param {string} format Output format + */ +function printMember (member, format) { + + switch (format) { + case 'json': { + Logger.println(JSON.stringify(member, null, 2)); + break; + } + case 'human': + default: { + console.table({ + Label: member.label, + Domain: member.domainName, + }); + } + } +} + +/** Print a Network Group peer + * @param {Object} peer The Network Group peer to print + * @param {string} format Output format + * @param {boolean} full If true, get more details about the peer (default: false) + */ +function printPeer (peer, format, full = false) { + switch (format) { + case 'json': { + Logger.println(JSON.stringify(peer, null, 2)); + break; + } + case 'human': + default: { + console.table(formatPeer(peer, full)); + } + } +} + +/** Format a peer to print + * @param {Object} peer + * @param {boolean} full If true, get more details about the peer (default: false) + */ +function formatPeer (peer, full = false) { + let peerToPrint = { + 'Parent Member': peer.parentMember, + ID: peer.id, + Label: peer.label, + Type: peer.type, + }; + + if (full) { + peerToPrint = { + ...peerToPrint, + [peer.endpoint.ngTerm ? 'Host:IP' : 'Host']: peer.endpoint.ngTerm + ? `${peer.endpoint.ngTerm.host}:${peer.endpoint.ngTerm.port}` + : peer.endpoint.ngIp, + ...(peer.endpoint.publicTerm && { + 'Public Term': `${peer.endpoint.publicTerm.host}:${peer.endpoint.publicTerm.port}`, + }), + 'Public Key': peer.publicKey, + }; + } + return peerToPrint; +} + +/** Print the results of a search or get action + * @param {object} idOrLabel ID or label of the Network Group, a member or a peer + * @param {object} org Organisation ID or name + * @param {string} format Output format + * @param {string} action Action to perform (search or get) + * @param {string} type Type of item to search (NetworkGroup, Member, Peer) + */ +export async function printResults (idOrLabel, org, format, action, type) { + + const exactMatch = action === 'get'; + type = type ?? (action === 'search' ? 'all' : 'single'); + + const found = await NG.searchNgOrResource(idOrLabel, org, type, exactMatch); + + if (!found.length) { + const searchString = idOrLabel.ngId + || idOrLabel.memberId + || idOrLabel.ngResourceLabel; + Logger.println(`${colors.blue('!')} No Network Group or resource found for ${colors.blue(searchString)}`); + return; + } + + if (found.length === 1) { + switch (found[0].type) { + case 'NetworkGroup': + return printNg(found[0], format, true); + case 'Member': + return printMember(found[0], format); + case 'CleverPeer': + case 'ExternalPeer': + return printPeer(found[0], format, true); + default: + throw new Error(`Unknown item type: ${found[0].type}`); + } + } + + if (action === 'search') { + // Group found items by type in a new object + const grouped = found.reduce((acc, item) => { + if (!acc[item.type]) { + acc[item.type] = []; + } + acc[item.type].push(item); + return acc; + }, {}); + + switch (format) { + case 'json': { + Logger.printJson(grouped); + break; + } + case 'human': + default: { + if (grouped.NetworkGroup) { + Logger.println(`${colors.bold(` • Found ${grouped.NetworkGroup.length} Network Group(s):`)}`); + grouped.NetworkGroup?.forEach((item) => printNg(item, format)); + } + + if (grouped.Member) { + Logger.println(`${colors.bold(` • Found ${grouped.Member.length} Member(s):`)}`); + grouped.Member?.forEach((item) => printMember(item, format)); + } + + if (grouped.ExternalPeer || grouped.CleverPeer) { + Logger.println(`${colors.bold(` • Found ${grouped.ExternalPeer.length + grouped.CleverPeer.length} Peer(s):`)}`); + grouped.CleverPeer?.forEach((item) => printPeer(item, format)); + grouped.ExternalPeer?.forEach((item) => printPeer(item, format)); + } + } + } + } +} diff --git a/src/commands/ng.js b/src/commands/ng.js new file mode 100644 index 0000000..af0e13e --- /dev/null +++ b/src/commands/ng.js @@ -0,0 +1,188 @@ +import colors from 'colors/safe.js'; +import { Logger } from '../logger.js'; +import * as NG from '../models/ng.js'; +import { printResults } from './ng-print.js'; +import * as NGResources from '../models/ng-resources.js'; + +/** Create a Network Group + * @param {Object} params + * @param {string} params.args[0] Network Group label + * @param {string} params.options.description Network Group description + * @param {Array} params.options.link Array of member IDs or labels to link to the Network Group + * @param {Object} params.options.org Organisation ID or name + * @param {string} params.options.tags Comma-separated list of tags + */ +export async function createNg (params) { + const label = params.args[0].ngResourceLabel; + const { description, link: membersIds, org, tags } = params.options; + + await NG.create(label, description, tags, membersIds, org); + + const membersIdsMessage = membersIds ? ` with member(s):\n${colors.grey(` - ${membersIds.join('\n - ')}`)}` : ''; + Logger.println(`${colors.bold.green('✓')} Network Group ${colors.green(label)} successfully created${membersIdsMessage}!`); +} + +/** Delete a Network Group + * @param {Object} params + * @param {Object} params.args[0] Network Group ID or label + * @param {Object} params.options.org Organisation ID or name + */ +export async function deleteNg (params) { + const [ngIdOrLabel] = params.args; + const { org } = params.options; + + await NG.destroy(ngIdOrLabel, org); + Logger.println(`${colors.bold.green('✓')} Network Group ${colors.green(ngIdOrLabel.ngResourceLabel || ngIdOrLabel.ngId)} successfully deleted!`); +} + +/** Create an external peer in a Network Group + * @param {Object} params + * @param {Object} params.args[0] External peer ID or label + * @param {Object} params.args[1] Network Group ID or label + * @param {string} params.args[2] Wireguard public key + * @param {Object} params.options.org Organisation ID or name + */ +export async function createExternalPeer (params) { + const [idOrLabel, ngIdOrLabel, publicKey] = params.args; + const { org } = params.options; + + await NGResources.createExternalPeerWithParent(ngIdOrLabel, idOrLabel.ngResourceLabel, publicKey, org); + Logger.println(`${colors.bold.green('✓')} External peer ${colors.green(idOrLabel.ngResourceLabel)} successfully created in Network Group ${colors.green(ngIdOrLabel.ngResourceLabel || ngIdOrLabel.ngId)}`); +} + +/** Delete an external peer from a Network Group + * @param {Object} params + * @param {Object} params.args[0] External peer ID or label + * @param {Object} params.args[1] Network Group ID or label + * @param {Object} params.options.org Organisation ID or name + */ +export async function deleteExternalPeer (params) { + const [idOrLabel, ngIdOrLabel] = params.args; + const { org } = params.options; + + await NGResources.deleteExternalPeerWithParent(ngIdOrLabel, idOrLabel.ngResourceLabel || idOrLabel.memberId, org); + Logger.println(`${colors.bold.green('✓')} External peer ${colors.green(idOrLabel.ngResourceLabel || idOrLabel.memberId)} successfully deleted from Network Group ${colors.green(ngIdOrLabel.ngResourceLabel || ngIdOrLabel.ngId)}`); +} + +/** Link a member to a Network Group + * @param {Object} params + * @param {string} params.args[0] Member ID + * @param {Object} params.args[1] Network Group ID or label + * @param {Object} params.options.org Organisation ID or name + */ +export async function linkToNg (params) { + const [resourceId, ngIdOrLabel] = params.args; + const { org } = params.options; + + await NGResources.linkMember(ngIdOrLabel, resourceId.memberId, org); + Logger.println(`${colors.bold.green('✓')} Member ${colors.green(resourceId.memberId)} successfully linked to Network Group ${colors.green(ngIdOrLabel.ngResourceLabel || ngIdOrLabel.ngId)}`); +} + +/** Unlink a member from a Network Group + * @param {Object} params + * @param {string} params.args[0] Member ID + * @param {Object} params.args[1] Network Group ID or label + * @param {Object} params.options.org Organisation ID or name + */ +export async function unlinkFromNg (params) { + const [resourceId, ngIdOrLabel] = params.args; + const { org } = params.options; + + await NGResources.unlinkMember(ngIdOrLabel, resourceId.memberId, org); + Logger.println(`${colors.bold.green('✓')} Member ${colors.green(resourceId.memberId)} successfully unlinked from Network Group ${colors.green(ngIdOrLabel.ngResourceLabel || ngIdOrLabel.ngId)}`); +} + +/** Print the configuration of a Network Group's peer + * @param {Object} params + * @param {Object} params.args[0] Peer ID or label + * @param {Object} params.args[1] Network Group ID or label + * @param {Object} params.options.org Organisation ID or name + * @param {string} params.options.format Output format + */ +export async function getPeerConfig (params) { + const [peerIdOrLabel, ngIdOrLabel] = params.args; + const { org, format } = params.options; + + const config = await NG.getPeerConfig(peerIdOrLabel, ngIdOrLabel, org); + + switch (format) { + case 'json': { + Logger.printJson(config); + break; + } + case 'human': + default: { + const decodedConfiguration = Buffer.from(config.configuration, 'base64').toString('utf8'); + Logger.println(decodedConfiguration); + } + } +} + +/** List Network Groups, their members and peers + * @param {Object} params + * @param {Object} params.options.orgaIdOrName Organisation ID or name + * @param {string} params.options.format Output format + */ +export async function listNg (params) { + const { org, format } = params.options; + + const ngs = await NG.getAllNGs(org); + + if (!ngs.length) { + Logger.println(`ℹ️ No Network Group found, create one with ${colors.blue('clever ng create')} command`); + return; + } + + switch (format) { + case 'json': { + Logger.printJson(ngs); + break; + } + case 'human': + default: { + const ngList = ngs.map(({ + id, + label, + networkIp, + members, + peers, + }) => ({ + ID: id, + Label: label, + 'Network CIDR': networkIp, + Members: Object.keys(members).length, + Peers: Object.keys(peers).length, + })); + + console.table(ngList); + } + } +} + +/** Show information about a Network Group, a member or a peer + * @param {Object} params + * @param {Object} params.args[0] ID or label of the Network Group, a member or a peer + * @param {Object} params.options.org Organisation ID or name + * @param {string} params.options.format Output format + */ +export async function get (params) { + const [idOrLabel] = params.args; + const { org, format } = params.options; + const type = params.options.type ?? 'single'; + + printResults(idOrLabel, org, format, 'get', type); +} + +/** Show information about a Network Group, a member or a peer + * @param {Object} params + * @param {Object} params.args[0] ID or label of the Network Group, a member or a peer + * @param {Object} params.options.org Organisation ID or name + * @param {string} params.options.format Output format + */ +export async function search (params) { + const [idOrLabel] = params.args; + const { org, format } = params.options; + const type = params.options.type; + + printResults(idOrLabel, org, format, 'search', type); +} diff --git a/src/experimental-features.js b/src/experimental-features.js index 4d67f94..82918bd 100644 --- a/src/experimental-features.js +++ b/src/experimental-features.js @@ -1,17 +1,59 @@ export const EXPERIMENTAL_FEATURES = { + functions: { + status: 'alpha', + description: 'Deploy and manage serverless Functions on Clever Cloud', + instructions: ` +Manage a function: + clever functions create + clever functions deploy function_xxx + clever functions delete function_xxx + +List functions and deployments: + + clever functions + clever functions list-deployments function_xxx + +Learn more about functions: https://github.com/CleverCloud/clever-tools/blob/davlgd-new-functions/docs/functions.md +`, + }, kv: { status: 'alpha', description: 'Send commands to databases such as Materia KV or Redis® directly from Clever Tools, without other dependencies', instructions: ` - Target any compatible add-on by its name or ID (with an org ID if needed) and send commands to it: +Target any compatible add-on by its name or ID (with an org ID if needed) and send commands to it: - clever kv myMateriaKV SET myKey myValue - clever kv kv_xxxxxxxx GET myKey -F json - clever kv addon_xxxxx SET myTempKey myTempValue EX 120 - clever kv myMateriaKV -o myOrg TTL myTempKey - clever kv redis_xxxxx --org org_xxxxx PING + clever kv myMateriaKV SET myKey myValue + clever kv kv_xxxxxxxx GET myKey -F json + clever kv addon_xxxxx SET myTempKey myTempValue EX 120 + clever kv myMateriaKV -o myOrg TTL myTempKey + clever kv redis_xxxxx --org org_xxxxx PING - Learn more about Materia KV: https://www.clever-cloud.com/developers/doc/addons/materia-kv/ - `, +Learn more about Materia KV: https://www.clever-cloud.com/developers/doc/addons/materia-kv/`, + }, + ng: { + status: 'beta', + description: 'Manage Network Groups to manage applications, add-ons, external peers through a Wireguard network', + instructions: ` +- Create a Network Group: + clever ng create myNG +- Create a Network Group with members (application, database add-on): + clever ng create myNG --link app_xxx,addon_xxx +- List Network Groups: + clever ng +- Delete a Network Group: + clever ng delete myNG +- (Un)Link an application or a database add-on to an existing Network Group: + clever ng link app_xxx myNG + clever ng unlink addon_xxx myNG +- Get the Wireguard configuration of a peer: + clever ng get-config peerIdOrLabel myNG +- Get details about a Network Group, a member or a peer: + clever ng get myNg + clever ng get app_xxx + clever ng get peerId + clever ng get memberLabel +- Search Network Groups, members or peers: + clever ng search myQuery +Learn more about Network Groups: https://github.com/CleverCloud/clever-tools/blob/davlgd-new-ng/docs/ng.md`, }, }; diff --git a/src/models/functions-api.js b/src/models/functions-api.js new file mode 100644 index 0000000..7c3ae84 --- /dev/null +++ b/src/models/functions-api.js @@ -0,0 +1,124 @@ +export function createFunction (params, body) { + return Promise.resolve({ + method: 'post', + url: `/v4/functions/organisations/${params.ownerId}/functions`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body, + }); +} + +export function getFunctions (params) { + return Promise.resolve({ + method: 'get', + url: `/v4/functions/organisations/${params.ownerId}/functions`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); +} + +export function getFunction (params) { + return Promise.resolve({ + method: 'get', + url: `/v4/functions/organisations/${params.ownerId}/functions/${params.functionId}`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); +} + +export function updateFunction (params, body) { + return Promise.resolve({ + method: 'put', + url: `/v4/functions/organisations/${params.ownerId}/functions/${params.functionId}`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body, + }); +} + +export function deleteFunction (params) { + return Promise.resolve({ + method: 'delete', + url: `/v4/functions/organisations/${params.ownerId}/functions/${params.functionId}`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); +} + +export function createDeployment (params, body) { + return Promise.resolve({ + method: 'post', + url: `/v4/functions/organisations/${params.ownerId}/functions/${params.functionId}/deployments`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body, + }); +} + +export function getDeployments (params) { + return Promise.resolve({ + method: 'get', + url: `/v4/functions/organisations/${params.ownerId}/functions/${params.functionId}/deployments`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); +} + +export function getDeployment (params) { + return Promise.resolve({ + method: 'get', + url: `/v4/functions/organisations/${params.ownerId}/functions/${params.functionId}/deployments/${params.deploymentId}`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); +} + +export function updateDeployment (params, body) { + return Promise.resolve({ + method: 'put', + url: `/v4/functions/organisations/${params.ownerId}/functions/${params.functionId}/deployments/${params.deploymentId}`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body, + }); +} + +export function deleteDeployment (params) { + return Promise.resolve({ + method: 'delete', + url: `/v4/functions/organisations/${params.ownerId}/functions/${params.functionId}/deployments/${params.deploymentId}`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); +} + +export function triggerDeployment (params) { + return Promise.resolve({ + method: 'post', + url: `/v4/functions/organisations/${params.ownerId}/functions/${params.functionId}/deployments/${params.deploymentId}/trigger`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); +} diff --git a/src/models/functions.js b/src/models/functions.js new file mode 100644 index 0000000..24ba7b0 --- /dev/null +++ b/src/models/functions.js @@ -0,0 +1,26 @@ +import fs from 'node:fs'; +import fetch from 'node-fetch'; +import FormData from 'form-data'; +import { env } from 'node:process'; + +const COMPILATOR_URL = env.COMPILATOR_URL; +const COMPILATOR_TOKEN = env.COMPILATOR_TOKEN; + +/** + * Upload a function to the Compilator service + * @param {string} uploadUrl - The URL of the upload endpoint + * @param {string} inputFilepath - The path to the function to upload + * @returns + */ +export function uploadFunction (uploadUrl, inputFilepath) { + const formData = new FormData(); + formData.append('file', fs.createReadStream(inputFilepath)); + return fetch(COMPILATOR_URL, { + method: 'POST', + body: formData, + headers: { + Authorization: `Bearer ${COMPILATOR_TOKEN}`, + 'X-Upload-URL': uploadUrl, + }, + }); +} diff --git a/src/models/ng-api.js b/src/models/ng-api.js new file mode 100644 index 0000000..4cdf2fa --- /dev/null +++ b/src/models/ng-api.js @@ -0,0 +1,18 @@ +// TODO: Move this to the Clever Cloud JS Client + +/** + * GET /networkgroups/organisations/{ownerId}/networkgroups/search?query + * @param {Object} params + * @param {String} params.undefined + * @param {String} params.undefined + */ +export function searchNetworkGroupOrResource (params) { + // no multipath for /self or /organisations/{id} + return Promise.resolve({ + method: 'get', + url: `/v4/networkgroups/organisations/${params.ownerId}/networkgroups/search`, + headers: { Accept: 'application/json' }, + queryParams: { query: params.query }, + // no body + }); +} diff --git a/src/models/ng-resources.js b/src/models/ng-resources.js new file mode 100644 index 0000000..0fa32fa --- /dev/null +++ b/src/models/ng-resources.js @@ -0,0 +1,262 @@ +import colors from 'colors/safe.js'; +import * as NG from './ng.js'; +import * as ngApi from '@clevercloud/client/cjs/api/v4/network-group.js'; + +import { v4 as uuidv4 } from 'uuid'; +import { Logger } from '../logger.js'; +import { sendToApi } from './send-to-api.js'; +import { getSummary } from '@clevercloud/client/cjs/api/v2/user.js'; + +/** + * Create an external peer and link its parent member to the Network Group + * @param {object} ngIdOrLabel The Network Group ID or Label + * @param {string} peerLabel External peer label + * @param {string} publicKey External peer public key + * @param {object} org Organisation ID or name + * @throws {Error} If a valid peer label is not provided + * @throws {Error} If the Network Group is not found + * @throws {Error} If the parent member is not linked to the Network Group + * @throws {Error} If the external peer is not linked to the Network Group + */ +export async function createExternalPeerWithParent (ngIdOrLabel, peerLabel, publicKey, org) { + + if (!peerLabel) { + throw new Error('A valid peer label is required'); + } + + const [ng] = await NG.searchNgOrResource(ngIdOrLabel, org, 'NetworkGroup'); + + if (!ng) { + throw new Error(`Network Group ${colors.red(ngIdOrLabel.ngId || ngIdOrLabel.ngResourceLabel)} not found`); + } + + // We define a parent member for the external peer + const id = `external_${uuidv4()}`; + const parentMember = { + id, + label: `Parent of ${peerLabel}`, + domainName: `${id}.m.${ng.id}.${NG.DOMAIN}`, + kind: 'EXTERNAL', + }; + + Logger.info(`Creating a parent member ${parentMember.id} linked to Network Group ${ng.id}`); + await linkMember({ ngId: ng.id }, parentMember.id, org, parentMember.label); + + const checkParentMember = await checkResource(ng.id, org, parentMember.id, true); + if (!checkParentMember) { + throw new Error(`Parent member ${colors.red(parentMember.id)} not linked to Network Group ${colors.red(ng.id)}`); + } + + Logger.info(`Parent member ${parentMember.id} created and linked to Network Group ${ng.id}`); + + // We define the external peer, for now we only support client role + const body = { + peerRole: 'CLIENT', + publicKey, + label: peerLabel, + parentMember: parentMember.id, + }; + + Logger.info(`Adding external peer to Member ${parentMember.id} of Network Group ${ng.id}`); + Logger.debug('Sending body: ' + JSON.stringify(body, null, 2)); + await ngApi.createNetworkGroupExternalPeer({ ownerId: ng.ownerId, networkGroupId: ng.id }, body).then(sendToApi); + + const checkExternalPeer = await checkResource(ng.id, org, peerLabel, true, 'peer', 'label'); + if (!checkExternalPeer) { + throw new Error(`External peer ${colors.red(peerLabel)} not linked to Network Group ${colors.red(ng.id)}`); + } + + Logger.info(`External peer ${peerLabel} added to Member ${parentMember.id} of Network Group ${ng.id}`); +} + +/** + * Delete an external peer and its parent member from a Network Group + * @param {object} ngIdOrLabel Network Group ID or label + * @param {string} peerIdOrLabel External peer ID or label + * @param {object} org Organisation ID or name + * @throws {Error} If the Network Group is not found + * @throws {Error} If the External Peer is not found + * @throws {Error} If the External Peer is still linked to the Network Group + * @throws {Error} If the Parent Member is still linked to the Network Group + */ +export async function deleteExternalPeerWithParent (ngIdOrLabel, peerIdOrLabel, org) { + + const [ng] = await NG.searchNgOrResource(ngIdOrLabel, org, 'NetworkGroup'); + + if (!ng) { + throw new Error(`Network Group ${colors.red(ngIdOrLabel.ngId || ngIdOrLabel.ngResourceLabel)} not found`); + } + + const externalPeer = peerIdOrLabel.startsWith('external_') + ? ng.peers.find((p) => p.id === peerIdOrLabel) + : ng.peers.find((p) => p.label === peerIdOrLabel); + + if (!externalPeer) { + throw new Error(`External peer ${colors.red(peerIdOrLabel)} not found`); + } + + Logger.info(`Deleting external peer ${externalPeer.id} from Network Group ${ng.id}`); + await ngApi.deleteNetworkGroupExternalPeer({ ownerId: ng.ownerId, networkGroupId: ng.id, peerId: externalPeer.id }).then(sendToApi); + + const checkPeer = await checkResource(ng.id, org, externalPeer.id, false, 'peer'); + if (!checkPeer) { + throw new Error(`External peer ${colors.red(externalPeer.id)} still linked to Network Group ${colors.red(ng.id)}`); + } + + Logger.info(`External peer ${externalPeer.id} deleted from Network Group ${ng.id}`); + Logger.info(`Unlinking parent member ${externalPeer.parentMember} from Network Group ${ng.id}`); + + await unlinkMember(ngIdOrLabel, externalPeer.parentMember, org); + + const checkParentMember = await checkResource(ng.id, org, externalPeer.parentMember, false); + if (!checkParentMember) { + throw new Error(`Parent member ${colors.red(externalPeer.parentMember)} still linked to Network Group ${colors.red(ng.id)}`); + } + + Logger.info(`Parent member ${externalPeer.parentMember} unlinked from Network Group ${ng.id}`); +} + +/** + * Link a Member to a Network Group + * @param {object} ngIdOrLabel The Network group ID or Label + * @param {string} memberId ID of the Member to link + * @param {object} org Organisation ID or name + * @param {string} label Label of the Member + */ +export async function linkMember (ngIdOrLabel, memberId, org, label) { + if (!memberId) { + throw new Error('A valid member ID is required (addon_xxx, app_xxx, external_xxx)'); + } + + const [ng] = await NG.searchNgOrResource(ngIdOrLabel, org, 'NetworkGroup'); + + if (!ng) { + throw new Error(`Network Group ${colors.red(ngIdOrLabel.ngId || ngIdOrLabel.ngResourceLabel)} not found`); + } + + await checkMembersToLink([memberId]); + + const alreadyMember = ng.members.find((m) => m.id === memberId); + if (alreadyMember) { + throw new Error(`Member ${colors.red(memberId)} is already linked to Network Group ${colors.red(ng.id)}`); + } + + const [member] = NG.constructMembers(ng.id, [memberId]); + + const body = { + id: member.id, + label: label || member.label, + domainName: member.domainName, + kind: member.kind, + }; + + Logger.info(`Linking member ${member.id} to Network Group ${ng.id}`); + Logger.debug('Sending body: ' + JSON.stringify(body, null, 2)); + await ngApi.createNetworkGroupMember({ ownerId: ng.ownerId, networkGroupId: ng.id }, body).then(sendToApi); + + const check = await checkResource(ng.id, org, member.id, true); + if (!check) { + throw new Error(`Member ${colors.red(member.id)} not linked to Network Group ${colors.red(ng.id)}`); + } + + Logger.info(`Member ${member.id} linked to Network Group ${ng.id}`); +} + +/** + * Unlink a Member from a Network Group + * @param {object} ngIdOrLabel The Network Group ID or Label + * @param {string} memberId The Member ID + * @param {object} org Organisation ID or name + * @throws {Error} If a valid member ID is not provided + * @throws {Error} If the Network Group is not found + * @throws {Error} If the Member is not found in the Network Group + * @throws {Error} If the Member is still linked to the Network Group + */ +export async function unlinkMember (ngIdOrLabel, memberId, org) { + if (!memberId) { + throw new Error('A valid member ID is required (addon_xxx, app_xxx, external_xxx)'); + } + + const [ng] = await NG.searchNgOrResource(ngIdOrLabel, org, 'NetworkGroup'); + + if (!ng) { + throw new Error(`Network Group ${colors.red(ngIdOrLabel.ngId || ngIdOrLabel.ngLabel)} not found`); + } + + const member = ng.members.find((m) => m.id === memberId); + if (!member) { + throw new Error(`Member ${colors.red(memberId)} not found in Network Group ${colors.red(ng.id)}`); + } + + Logger.info(`Unlinking member ${memberId} from Network Group ${ng.id}`); + await ngApi.deleteNetworkGroupMember({ ownerId: ng.ownerId, networkGroupId: ng.id, memberId }).then(sendToApi); + + const check = await checkResource(ng.id, org, memberId, false); + if (!check) { + throw new Error(`Member ${colors.red(memberId)} still linked to Network Group ${colors.red(ng.id)}`); + } + + Logger.info(`Member ${memberId} unlinked from Network Group ${ng.id}`); +} + +/** + * Check if members can be linked to a Network Group + * @param {Array} members Members to check + * @throws {Error} If members can't be linked to a Network Group + */ +export async function checkMembersToLink (members) { + const VALID_ADDON_PROVIDERS = [ + 'es-addon', + 'mongodb-addon', + 'mysql-addon', + 'postgresql-addon', + 'redis-addon', + ]; + + const summary = await getSummary().then(sendToApi); + const membersNotOK = []; + for (const memberId of members) { + + let source = summary.user.applications; + if (memberId.startsWith('addon_')) source = summary.user.addons; + + const foundRessource = source.find((r) => r.id === memberId); + + if (foundRessource && memberId.startsWith('addon_') && !VALID_ADDON_PROVIDERS.includes(foundRessource.providerId)) { + membersNotOK.push(memberId); + } + else if (!foundRessource && !memberId.startsWith('external_')) { + membersNotOK.push(memberId); + } + } + + if (membersNotOK.length > 0) { + Logger.error(`Member(s) ${colors.red(membersNotOK.join(', '))} can't be linked to a Network Group`); + process.exit(1); + } +} + +/** + * Check if a resource is present in a Network Group by ID or label + * @param {string} ngId Network Group ID + * @param {object} org Organisation ID or name + * @param {string} resource Resource ID or label + * @param {boolean} shouldBePresent Expected presence of the resource + * @param {string} [resourceType] Resource type (member or peer), default is member + * @param {string} [searchBy] Search by 'id' or 'label', default is 'id' + * @returns {Promise} True if the resource is present, false otherwise + */ +async function checkResource (ngId, org, resource, shouldBePresent, resourceType = 'member', searchBy = 'id') { + const endTime = Date.now() + NG.TIMEOUT * 1000; + + while (Date.now() < endTime) { + const ng = await NG.getNG(ngId, org); + const items = resourceType === 'member' ? ng.members : ng.peers; + const isPresent = items.some((item) => item[searchBy] === resource); + + if (isPresent === shouldBePresent) return true; + + await new Promise((resolve) => setTimeout(resolve, NG.INTERVAL)); + } + return false; +} diff --git a/src/models/ng.js b/src/models/ng.js new file mode 100644 index 0000000..29db96f --- /dev/null +++ b/src/models/ng.js @@ -0,0 +1,279 @@ +import colors from 'colors/safe.js'; +import * as User from '../models/user.js'; +import * as Organisation from '../models/organisation.js'; +import * as ngApi from '@clevercloud/client/cjs/api/v4/network-group.js'; + +import { searchNetworkGroupOrResource } from './ng-api.js'; +import { checkMembersToLink } from './ng-resources.js'; +import { sendToApi } from './send-to-api.js'; +import { Logger } from '../logger.js'; +import { v4 as uuidv4 } from 'uuid'; + +export const TIMEOUT = 30; +export const INTERVAL = 1000; +export const DOMAIN = 'ng-cc.cloud'; +export const TYPE_PREFIXES = { + app_: 'APPLICATION', + addon_: 'ADDON', + external_: 'EXTERNAL', +}; + +/** + * Ask for a Network Group creation + * @param {string} label The Network Group label + * @param {string} description The Network Group description + * @param {string} tags The Network Group tags + * @param {Array} membersIds The members to link to the Network Group + * @param {string} orgaIdOrName The owner ID or name + * @throws {Error} If the Network Group label is missing + */ +export async function create (label, description, tags, membersIds, orgaIdOrName) { + if (!label) { + throw new Error('A valid Network Group label is required'); + } + + if (membersIds?.length > 0) { + await checkMembersToLink(membersIds); + } + + const id = `ng_${uuidv4()}`; + const ownerId = await getOwnerIdFromOrgaIdOrName(orgaIdOrName); + + const members = constructMembers(id, membersIds || []); + const body = { ownerId, id, label, description, tags, members }; + + Logger.info(`Creating Network Group ${label} (${id}) from owner ${ownerId}`); + Logger.info(`${members.length} members will be added: ${members.map((m) => m.id).join(', ')}`); + Logger.debug(`Sending body: ${JSON.stringify(body, null, 2)}`); + await ngApi.createNetworkGroup({ ownerId }, body).then(sendToApi); + + await pollNetworkGroup(ownerId, id, { waitForMembers: membersIds }); + Logger.info(`Network Group ${label} (${id}) created from owner ${ownerId}`); +} + +/** + * Ask for a Network Group deletion + * @param {object} ngIdOrLabel The Network Group ID or Label + * @param {object} orgaIdOrName The owner ID or name + * @throws {Error} If the Network Group is not found + */ +export async function destroy (ngIdOrLabel, orgaIdOrName) { + const [found] = await searchNgOrResource(ngIdOrLabel, orgaIdOrName, 'NetworkGroup'); + + if (!found) { + throw new Error(`Network Group ${colors.red(ngIdOrLabel.ngId || ngIdOrLabel.ngResourceLabel)} not found`); + } + + await ngApi.deleteNetworkGroup({ ownerId: found.ownerId, networkGroupId: found.id }).then(sendToApi); + Logger.info(`Deleting Network Group ${found.id} from owner ${found.ownerId}`); + await pollNetworkGroup(found.ownerId, found.id, { waitForDeletion: true }); + Logger.info(`Network Group ${found.id} deleted from owner ${found.ownerId}`); +} + +/** + * Get the Wireguard configuration of a Network Group peer + * @param {object} peerIdOrLabel The Peer ID or Label + * @param {object} ngIdOrLabel The Network Group ID or Label + * @param {object} orgaIdOrName The owner ID or name + * @returns {Promise} The Peer Wireguard configuration + * @throws {Error} If the Peer is not found + * @throws {Error} If the Network Group is not found + * @throws {Error} If the Peer is not in the Network Group + */ +export async function getPeerConfig (peerIdOrLabel, ngIdOrLabel, orgaIdOrName) { + const [parentNg] = await searchNgOrResource(ngIdOrLabel, orgaIdOrName, 'NetworkGroup'); + + if (!parentNg) { + throw new Error(`Network Group ${colors.red(ngIdOrLabel.ngId || ngIdOrLabel.ngResourceLabel)} not found`); + } + + const [peer] = await searchNgOrResource(peerIdOrLabel, orgaIdOrName, 'Peer'); + + if (!peer || (peerIdOrLabel.ngResourceLabel && peer.label !== peerIdOrLabel.ngResourceLabel)) { + throw new Error(`Peer ${colors.red(peerIdOrLabel.ngResourceLabel || peerIdOrLabel.member)} not found`); + } + + if (!parentNg.peers.find((p) => p.id === peer.id)) { + throw new Error(`Peer ${colors.red(peer.id)} is not in Network Group ${colors.red(parentNg.id)}`); + } + + Logger.debug(`Getting configuration for Peer ${peer.id}`); + const result = await ngApi.getNetworkGroupWireGuardConfiguration({ + ownerId: parentNg.ownerId, + networkGroupId: parentNg.id, + peerId: peer.id, + }).then(sendToApi); + Logger.debug(`Received from API:\n${JSON.stringify(result, null, 2)}`); + + return result; +} + +/** + * Get a Network group from an owner with members and peers + * @param {string} networkGroupId The Network Group ID + * @param {string} orgaIdOrName The owner ID or name + * @returns {Promise>} The Network Groups + */ +export async function getNG (networkGroupId, orgaIdOrName) { + const ownerId = await getOwnerIdFromOrgaIdOrName(orgaIdOrName); + + Logger.info(`Get Network Group ${networkGroupId} for owner ${ownerId}`); + const result = await ngApi.getNetworkGroup({ networkGroupId, ownerId }).then(sendToApi); + Logger.debug(`Received from API:\n${JSON.stringify(result, null, 2)}`); + + return result; +} + +/** + * Get all Network Groups from an owner with members and peers + * @param {string} orgaIdOrName The owner ID or name + * @returns {Promise>} The Network Groups + */ +export async function getAllNGs (orgaIdOrName) { + const ownerId = await getOwnerIdFromOrgaIdOrName(orgaIdOrName); + + Logger.info(`Listing Network Groups from owner ${ownerId}`); + const result = await ngApi.listNetworkGroups({ ownerId }).then(sendToApi); + Logger.debug(`Received from API:\n${JSON.stringify(result, null, 2)}`); + return result; +} + +/** + * Search a Network Group or a resource (member/peer) + * @param {string|Object} idOrLabel The ID or label to look for + * @param {Object} orgaIdOrName The owner ID or name + * @param {string} [type] Look only for a specific type (NetworkGroup, Member, CleverPeer, ExternalPeer, Peer), can be 'single', default to 'all' + * @param {boolean} exactMatch Look for exact match, default to true + * @throws {Error} If multiple Network Groups or member/peer are found in single_result mode + * @returns {Promise} Found results + */ +export async function searchNgOrResource (idOrLabel, orgaIdOrName, type = 'all', exactMatch = true) { + const ownerId = await getOwnerIdFromOrgaIdOrName(orgaIdOrName); + + // If idOrLabel is a string we use it, or we look through multiple keys + const query = typeof idOrLabel === 'string' + ? idOrLabel + : ( + idOrLabel.ngId + || idOrLabel.memberId + || idOrLabel.ngResourceLabel + ); + + const found = await searchNetworkGroupOrResource({ ownerId, query }).then(sendToApi); + + let filtered = found; + switch (type) { + case 'all': + case 'single': + break; + case 'Peer': + filtered = found.filter((f) => f.type === 'CleverPeer' || f.type === 'ExternalPeer'); + break; + case 'CleverPeer': + case 'ExternalPeer': + case 'Member': + case 'NetworkGroup': + filtered = found.filter((f) => f.type === type); + break; + default: + throw new Error(`Unsupported type: ${type}`); + } + + if (exactMatch) { + filtered = filtered.filter((f) => f.id === query || f.label === query); + } + + if (filtered.length > 1 && type !== 'all') { + throw new Error(`Multiple resources found for ${colors.red(query)}, use ID instead: +${filtered.map((f) => ` • ${f.id} ${colors.grey(`(${f.label} - ${f.type})`)}`).join('\n')}`); + } + + // Deduplicate results + return filtered.filter((item, index, array) => array.findIndex((element) => (element.id === item.id)) === index); +} + +/** + * Construct members from members_ids + * @param {string} ngId The Network Group ID + * @param {Array} membersIds The members IDs + * @returns {Array} Array of members with id, domainName and kind + */ +export function constructMembers (ngId, membersIds) { + return membersIds.map((id) => { + const domainName = `${id}.m.${ngId}.${DOMAIN}`; + const prefixToType = TYPE_PREFIXES; + + return { + id, + domainName, + // Get kind from prefix match in id (app_*, addon_*, external_*) or default to 'APPLICATION' + kind: prefixToType[Object.keys(prefixToType).find((p) => id.startsWith(p))] + || TYPE_PREFIXES.app_, + }; + }); +} + +/** + * Poll Network Groups to check its status and members + * @param {string} ownerId The owner ID + * @param {string} ngId The Network Group ID + * @param {Array} waitForMembers The members IDs to wait for + * @param {boolean} waitForDeletion Wait for the Network Group deletion + * @throws {Error} When timeout is reached + * @returns {Promise} + */ +async function pollNetworkGroup (ownerId, ngId, { waitForMembers = null, waitForDeletion = false } = {}) { + return new Promise((resolve, reject) => { + Logger.info(`Polling Network Groups from owner ${ownerId}`); + const timeoutTime = Date.now() + (TIMEOUT * 1000); + + async function pollOnce () { + if (Date.now() > timeoutTime) { + const action = waitForDeletion ? 'deletion of' : 'creation of'; + reject(new Error(`Timeout while checking ${action} Network Group ${ngId}`)); + return; + } + + try { + const ngs = await ngApi.listNetworkGroups({ ownerId }).then(sendToApi); + const ng = ngs.find((ng) => ng.id === ngId); + + if (waitForDeletion && !ng) { + resolve(); + return; + } + + if (!waitForDeletion && ng) { + if (waitForMembers?.length) { + const members = ng.members.filter((member) => waitForMembers.includes(member.id)); + if (members.length !== waitForMembers.length) { + Logger.debug(`Waiting for members: ${waitForMembers.join(', ')}`); + setTimeout(pollOnce, INTERVAL); + return; + } + } + resolve(); + return; + } + + setTimeout(pollOnce, INTERVAL); + } + catch (error) { + reject(error); + } + } + + pollOnce(); + }); +} + +/** + * Get the owner ID from an Organisation ID or name + * @param {object} orgaIdOrName The Organisation ID or name + * @returns {Promise} The owner ID + */ +async function getOwnerIdFromOrgaIdOrName (orgaIdOrName) { + return orgaIdOrName != null + ? Organisation.getId(orgaIdOrName) + : User.getCurrentId(); +} diff --git a/src/parsers.js b/src/parsers.js index c11b5fd..cca09d4 100644 --- a/src/parsers.js +++ b/src/parsers.js @@ -175,3 +175,23 @@ export function durationInSeconds (durationStr = '') { return cliparse.parsers.success(n); } } + +// Network groups parsers +export function ngResourceType (string) { + if (string.startsWith('ng_')) { + return cliparse.parsers.success({ ngId: string }); + } + if (string.startsWith('app_') || string.startsWith('addon_') || string.startsWith('external_')) { + return cliparse.parsers.success({ memberId: string }); + } + return cliparse.parsers.success({ ngResourceLabel: string }); +} + +export function ngValidType (string) { + if (string === 'NetworkGroup' || string === 'Member' || string === 'CleverPeer' || string === 'ExternalPeer') { + return cliparse.parsers.success(string); + } + else { + return cliparse.parsers.error('Invalid Network Group resource type: ' + string); + } +}