From 6058b750a6f8e6c3013d52bb6452b1f6e9c13416 Mon Sep 17 00:00:00 2001 From: David Legrand <1110600+davlgd@users.noreply.github.com> Date: Tue, 28 Jan 2025 09:19:56 +0100 Subject: [PATCH 01/10] docs: update Network Groups usage instructions Co-Authored-By: kannar --- docs/README.md | 1 + docs/networkgroups/commands.md | 167 ------------------ docs/networkgroups/tests.md | 310 --------------------------------- docs/ng.md | 126 ++++++++++++++ 4 files changed, 127 insertions(+), 477 deletions(-) delete mode 100644 docs/networkgroups/commands.md delete mode 100644 docs/networkgroups/tests.md create mode 100644 docs/ng.md 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..e671433 --- /dev/null +++ b/docs/ng.md @@ -0,0 +1,126 @@ +# 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 query +``` + +## 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 +``` + +## 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)! From 6948297707d03a451fe1f8b948a7f54b0c54784e Mon Sep 17 00:00:00 2001 From: David Legrand <1110600+davlgd@users.noreply.github.com> Date: Tue, 28 Jan 2025 09:20:38 +0100 Subject: [PATCH 02/10] chore: add Network Groups parsers --- src/parsers.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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); + } +} From 27b44aa06930e389c71fed397fd37c8d637efa31 Mon Sep 17 00:00:00 2001 From: David Legrand <1110600+davlgd@users.noreply.github.com> Date: Tue, 28 Jan 2025 09:21:28 +0100 Subject: [PATCH 03/10] feat: add ng command and feature flag --- bin/clever.js | 118 ++++++++++++++++++++++++++++++++++- src/experimental-features.js | 41 +++++++++--- 2 files changed, 150 insertions(+), 9 deletions(-) diff --git a/bin/clever.js b/bin/clever.js index 557a0b0..685f569 100755 --- a/bin/clever.js +++ b/bin/clever.js @@ -44,6 +44,7 @@ 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 +91,43 @@ async function run () { // ARGUMENTS const args = { - kvRawCommand: cliparse.argument('command', { description: 'The raw command to send to the Materia KV or Redis® add-on' }), + 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 +181,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']), @@ -746,6 +804,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', @@ -982,6 +1094,10 @@ async function run () { commands.push(colorizeExperimentalCommand(kvRawCommand, 'kv')); } + if (featuresFromConf.ng) { + commands.push(colorizeExperimentalCommand(networkGroupsCommand, 'ng')); + } + // CLI PARSER const cliParser = cliparse.cli({ name: 'clever', diff --git a/src/experimental-features.js b/src/experimental-features.js index 4d67f94..f74728a 100644 --- a/src/experimental-features.js +++ b/src/experimental-features.js @@ -3,15 +3,40 @@ export const EXPERIMENTAL_FEATURES = { 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 +- 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`, }, }; From d042386aeeeb103322020e2bbfa949af4dd3f19c Mon Sep 17 00:00:00 2001 From: David Legrand <1110600+davlgd@users.noreply.github.com> Date: Tue, 28 Jan 2025 09:22:17 +0100 Subject: [PATCH 04/10] feat(ng): add ng commands and tools --- src/commands/ng-print.js | 189 ++++++++++++++++++++++++++ src/commands/ng.js | 190 ++++++++++++++++++++++++++ src/models/ng-api.js | 18 +++ src/models/ng-resources.js | 259 +++++++++++++++++++++++++++++++++++ src/models/ng.js | 271 +++++++++++++++++++++++++++++++++++++ 5 files changed, 927 insertions(+) create mode 100644 src/commands/ng-print.js create mode 100644 src/commands/ng.js create mode 100644 src/models/ng-api.js create mode 100644 src/models/ng-resources.js create mode 100644 src/models/ng.js diff --git a/src/commands/ng-print.js b/src/commands/ng-print.js new file mode 100644 index 0000000..85dfd2c --- /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) + */ +export function ng (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 + */ +export function member (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) + */ +export function peer (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) + */ +export 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 results (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 ng(found[0], format, true); + case 'Member': + return member(found[0], format); + case 'CleverPeer': + case 'ExternalPeer': + return peer(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) => ng(item, format)); + } + + if (grouped.Member) { + Logger.println(`${colors.bold(` • Found ${grouped.Member.length} Member(s):`)}`); + grouped.Member?.forEach((item) => member(item, format)); + } + + if (grouped.ExternalPeer || grouped.CleverPeer) { + Logger.println(`${colors.bold(` • Found ${grouped.ExternalPeer.length + grouped.CleverPeer.length} Peer(s):`)}`); + grouped.CleverPeer?.forEach((item) => peer(item, format)); + grouped.ExternalPeer?.forEach((item) => peer(item, format)); + } + } + } + } +} diff --git a/src/commands/ng.js b/src/commands/ng.js new file mode 100644 index 0000000..7d7e132 --- /dev/null +++ b/src/commands/ng.js @@ -0,0 +1,190 @@ +import colors from 'colors/safe.js'; +import { Logger } from '../logger.js'; +import * as NG from '../models/ng.js'; +import * as NGPrint 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; + let type = params.options.type; + + if (!type) type = 'single'; + + NGPrint.results(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; + + NGPrint.results(idOrLabel, org, format, 'search', type); +} 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..599497b --- /dev/null +++ b/src/models/ng-resources.js @@ -0,0 +1,259 @@ +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.ngLabel)} 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.ngLabel)} 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'); + } + + checkMembersToLink([memberId]); + + const [ng] = await NG.searchNgOrResource(ngIdOrLabel, org, 'NetworkGroup'); + + if (!ng) { + throw new Error(`Network Group ${colors.red(ngIdOrLabel.ngId || ngIdOrLabel.ngResourceLabel)} not found`); + } + + 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'); + } + + 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 && !VALID_ADDON_PROVIDERS.includes(foundRessource.providerId)) { + 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..f4fb20c --- /dev/null +++ b/src/models/ng.js @@ -0,0 +1,271 @@ +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'); + } + + 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 [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`); + } + + const [parentNg] = await searchNgOrResource(ngIdOrLabel, orgaIdOrName, 'NetworkGroup'); + + if (!parentNg) { + throw new Error(`Network Group ${colors.red(ngIdOrLabel.ngId || ngIdOrLabel.ngResourceLabel)} not found`); + } + + if (!parentNg.peers[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 {object} ngIdOrLabel The Network Group ID or Label + * @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 {boolean} 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} members_ids 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 poll = setInterval(async () => { + // We don't use ngApi.getNetworkGroup(), it will lead to an error before creation + const ngs = await ngApi.listNetworkGroups({ ownerId }).then(sendToApi); + const ng = ngs.find((ng) => ng.id === ngId); + + if (waitForDeletion && !ng) { + cleanup(true); + 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(', ')}`); + return; + } + } + cleanup(true); + } + }, INTERVAL); + + const timer = setTimeout(() => { + const action = waitForDeletion ? 'deletion of' : 'creation of'; + cleanup(false, new Error(`Timeout while checking ${action} Network Group ${ngId}`)); + }, TIMEOUT * 1000); + + function cleanup (success, error = null) { + clearInterval(poll); + clearTimeout(timer); + success ? resolve() : reject(error); + } + }); +} + +/** + * 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(); +} From c96c7f56af92464ed35edc64143d3be66c6777c4 Mon Sep 17 00:00:00 2001 From: David Legrand <1110600+davlgd@users.noreply.github.com> Date: Wed, 29 Jan 2025 16:37:05 +0100 Subject: [PATCH 05/10] fix(ng): peer ID matching while getting config --- src/models/ng.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/ng.js b/src/models/ng.js index f4fb20c..a84a8e7 100644 --- a/src/models/ng.js +++ b/src/models/ng.js @@ -91,7 +91,7 @@ export async function getPeerConfig (peerIdOrLabel, ngIdOrLabel, orgaIdOrName) { throw new Error(`Network Group ${colors.red(ngIdOrLabel.ngId || ngIdOrLabel.ngResourceLabel)} not found`); } - if (!parentNg.peers[peer.id]) { + 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)}`); } From 838bdf18a9f58b9ede2275447e68b502d845b3c3 Mon Sep 17 00:00:00 2001 From: David Legrand <1110600+davlgd@users.noreply.github.com> Date: Wed, 29 Jan 2025 16:42:46 +0100 Subject: [PATCH 06/10] fix(ng): create with no members to link --- src/models/ng.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/models/ng.js b/src/models/ng.js index a84a8e7..3e11459 100644 --- a/src/models/ng.js +++ b/src/models/ng.js @@ -32,7 +32,9 @@ export async function create (label, description, tags, membersIds, orgaIdOrName throw new Error('A valid Network Group label is required'); } - await checkMembersToLink(membersIds); + if (membersIds && membersIds.length > 0) { + await checkMembersToLink(membersIds); + } const id = `ng_${uuidv4()}`; const ownerId = await getOwnerIdFromOrgaIdOrName(orgaIdOrName); From 71b23a0ec5b576b661496cf37d021d6ce3c82528 Mon Sep 17 00:00:00 2001 From: David Legrand <1110600+davlgd@users.noreply.github.com> Date: Wed, 29 Jan 2025 16:54:08 +0100 Subject: [PATCH 07/10] fix(ng): better check members to link to a Network Group --- src/models/ng-resources.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/models/ng-resources.js b/src/models/ng-resources.js index 599497b..ce003d7 100644 --- a/src/models/ng-resources.js +++ b/src/models/ng-resources.js @@ -222,7 +222,10 @@ export async function checkMembersToLink (members) { const foundRessource = source.find((r) => r.id === memberId); - if (foundRessource && !VALID_ADDON_PROVIDERS.includes(foundRessource.providerId)) { + if (foundRessource && memberId.startsWith('addon_') && !VALID_ADDON_PROVIDERS.includes(foundRessource.providerId)) { + membersNotOK.push(memberId); + } + else if (!foundRessource) { membersNotOK.push(memberId); } }; From 54013b5e88d31efb68e3f17612d45b06807f82a1 Mon Sep 17 00:00:00 2001 From: David Legrand <1110600+davlgd@users.noreply.github.com> Date: Wed, 29 Jan 2025 17:05:49 +0100 Subject: [PATCH 08/10] fix(ng): don't block link external peers --- src/models/ng-resources.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/ng-resources.js b/src/models/ng-resources.js index ce003d7..c8ddc6e 100644 --- a/src/models/ng-resources.js +++ b/src/models/ng-resources.js @@ -225,7 +225,7 @@ export async function checkMembersToLink (members) { if (foundRessource && memberId.startsWith('addon_') && !VALID_ADDON_PROVIDERS.includes(foundRessource.providerId)) { membersNotOK.push(memberId); } - else if (!foundRessource) { + else if (!foundRessource && !memberId.startsWith('external_')) { membersNotOK.push(memberId); } }; From f0ea21545b973fdf555bfd26bfc057852ace641e Mon Sep 17 00:00:00 2001 From: David Legrand <1110600+davlgd@users.noreply.github.com> Date: Thu, 30 Jan 2025 16:43:06 +0100 Subject: [PATCH 09/10] fix: after Pierre's review --- docs/ng.md | 8 +++- src/commands/ng-print.js | 24 +++++------ src/commands/ng.js | 10 ++--- src/experimental-features.js | 2 +- src/models/ng-resources.js | 18 ++++---- src/models/ng.js | 80 +++++++++++++++++++----------------- 6 files changed, 75 insertions(+), 67 deletions(-) diff --git a/docs/ng.md b/docs/ng.md index e671433..7bfb076 100644 --- a/docs/ng.md +++ b/docs/ng.md @@ -106,15 +106,19 @@ clever ng get resourceIdOrName You can also search for network groups, members or peers: ``` -clever ng search query +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 +clever ng get-config peerIdOrLabel myNG ``` ## Demos & examples diff --git a/src/commands/ng-print.js b/src/commands/ng-print.js index 85dfd2c..d863520 100644 --- a/src/commands/ng-print.js +++ b/src/commands/ng-print.js @@ -7,7 +7,7 @@ import * as NG from '../models/ng.js'; * @param {string} format Output format * @param {boolean} full If true, get more details about the Network Group (default: false) */ -export function ng (ng, format, full = false) { +function printNg (ng, format, full = false) { switch (format) { case 'json': { @@ -53,7 +53,7 @@ export function ng (ng, format, full = false) { * @param {Object} member The Network Group member to print * @param {string} format Output format */ -export function member (member, format) { +function printMember (member, format) { switch (format) { case 'json': { @@ -75,7 +75,7 @@ export function member (member, format) { * @param {string} format Output format * @param {boolean} full If true, get more details about the peer (default: false) */ -export function peer (peer, format, full = false) { +function printPeer (peer, format, full = false) { switch (format) { case 'json': { Logger.println(JSON.stringify(peer, null, 2)); @@ -92,7 +92,7 @@ export function peer (peer, format, full = false) { * @param {Object} peer * @param {boolean} full If true, get more details about the peer (default: false) */ -export function formatPeer (peer, full = false) { +function formatPeer (peer, full = false) { let peerToPrint = { 'Parent Member': peer.parentMember, ID: peer.id, @@ -122,7 +122,7 @@ export function formatPeer (peer, full = false) { * @param {string} action Action to perform (search or get) * @param {string} type Type of item to search (NetworkGroup, Member, Peer) */ -export async function results (idOrLabel, org, format, action, type) { +export async function printResults (idOrLabel, org, format, action, type) { const exactMatch = action === 'get'; type = type ?? (action === 'search' ? 'all' : 'single'); @@ -140,12 +140,12 @@ export async function results (idOrLabel, org, format, action, type) { if (found.length === 1) { switch (found[0].type) { case 'NetworkGroup': - return ng(found[0], format, true); + return printNg(found[0], format, true); case 'Member': - return member(found[0], format); + return printMember(found[0], format); case 'CleverPeer': case 'ExternalPeer': - return peer(found[0], format, true); + return printPeer(found[0], format, true); default: throw new Error(`Unknown item type: ${found[0].type}`); } @@ -170,18 +170,18 @@ export async function results (idOrLabel, org, format, action, type) { default: { if (grouped.NetworkGroup) { Logger.println(`${colors.bold(` • Found ${grouped.NetworkGroup.length} Network Group(s):`)}`); - grouped.NetworkGroup?.forEach((item) => ng(item, format)); + grouped.NetworkGroup?.forEach((item) => printNg(item, format)); } if (grouped.Member) { Logger.println(`${colors.bold(` • Found ${grouped.Member.length} Member(s):`)}`); - grouped.Member?.forEach((item) => member(item, format)); + 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) => peer(item, format)); - grouped.ExternalPeer?.forEach((item) => peer(item, format)); + 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 index 7d7e132..af0e13e 100644 --- a/src/commands/ng.js +++ b/src/commands/ng.js @@ -1,7 +1,7 @@ import colors from 'colors/safe.js'; import { Logger } from '../logger.js'; import * as NG from '../models/ng.js'; -import * as NGPrint from './ng-print.js'; +import { printResults } from './ng-print.js'; import * as NGResources from '../models/ng-resources.js'; /** Create a Network Group @@ -168,11 +168,9 @@ export async function listNg (params) { export async function get (params) { const [idOrLabel] = params.args; const { org, format } = params.options; - let type = params.options.type; + const type = params.options.type ?? 'single'; - if (!type) type = 'single'; - - NGPrint.results(idOrLabel, org, format, 'get', type); + printResults(idOrLabel, org, format, 'get', type); } /** Show information about a Network Group, a member or a peer @@ -186,5 +184,5 @@ export async function search (params) { const { org, format } = params.options; const type = params.options.type; - NGPrint.results(idOrLabel, org, format, 'search', type); + printResults(idOrLabel, org, format, 'search', type); } diff --git a/src/experimental-features.js b/src/experimental-features.js index f74728a..64f7157 100644 --- a/src/experimental-features.js +++ b/src/experimental-features.js @@ -29,7 +29,7 @@ Learn more about Materia KV: https://www.clever-cloud.com/developers/doc/addons/ clever ng link app_xxx myNG clever ng unlink addon_xxx myNG - Get the Wireguard configuration of a peer: - clever ng get-config peerIdOrLabel + 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 diff --git a/src/models/ng-resources.js b/src/models/ng-resources.js index c8ddc6e..0fa32fa 100644 --- a/src/models/ng-resources.js +++ b/src/models/ng-resources.js @@ -27,7 +27,7 @@ export async function createExternalPeerWithParent (ngIdOrLabel, peerLabel, publ const [ng] = await NG.searchNgOrResource(ngIdOrLabel, org, 'NetworkGroup'); if (!ng) { - throw new Error(`Network Group ${colors.red(ngIdOrLabel.ngId || ngIdOrLabel.ngLabel)} not found`); + throw new Error(`Network Group ${colors.red(ngIdOrLabel.ngId || ngIdOrLabel.ngResourceLabel)} not found`); } // We define a parent member for the external peer @@ -84,7 +84,7 @@ export async function deleteExternalPeerWithParent (ngIdOrLabel, peerIdOrLabel, const [ng] = await NG.searchNgOrResource(ngIdOrLabel, org, 'NetworkGroup'); if (!ng) { - throw new Error(`Network Group ${colors.red(ngIdOrLabel.ngId || ngIdOrLabel.ngLabel)} not found`); + throw new Error(`Network Group ${colors.red(ngIdOrLabel.ngId || ngIdOrLabel.ngResourceLabel)} not found`); } const externalPeer = peerIdOrLabel.startsWith('external_') @@ -125,17 +125,17 @@ export async function deleteExternalPeerWithParent (ngIdOrLabel, peerIdOrLabel, */ export async function linkMember (ngIdOrLabel, memberId, org, label) { if (!memberId) { - throw new Error('A valid member ID is required'); + throw new Error('A valid member ID is required (addon_xxx, app_xxx, external_xxx)'); } - checkMembersToLink([memberId]); - 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)}`); @@ -174,7 +174,7 @@ export async function linkMember (ngIdOrLabel, memberId, org, label) { */ export async function unlinkMember (ngIdOrLabel, memberId, org) { if (!memberId) { - throw new Error('A valid member ID is required'); + throw new Error('A valid member ID is required (addon_xxx, app_xxx, external_xxx)'); } const [ng] = await NG.searchNgOrResource(ngIdOrLabel, org, 'NetworkGroup'); @@ -228,7 +228,7 @@ export async function checkMembersToLink (members) { 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`); @@ -242,8 +242,8 @@ export async function checkMembersToLink (members) { * @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' + * @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') { diff --git a/src/models/ng.js b/src/models/ng.js index 3e11459..29db96f 100644 --- a/src/models/ng.js +++ b/src/models/ng.js @@ -32,7 +32,7 @@ export async function create (label, description, tags, membersIds, orgaIdOrName throw new Error('A valid Network Group label is required'); } - if (membersIds && membersIds.length > 0) { + if (membersIds?.length > 0) { await checkMembersToLink(membersIds); } @@ -81,18 +81,18 @@ export async function destroy (ngIdOrLabel, orgaIdOrName) { * @throws {Error} If the Peer is not in the Network Group */ export async function getPeerConfig (peerIdOrLabel, ngIdOrLabel, orgaIdOrName) { - 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`); - } - 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)}`); } @@ -110,7 +110,7 @@ export async function getPeerConfig (peerIdOrLabel, ngIdOrLabel, orgaIdOrName) { /** * Get a Network group from an owner with members and peers - * @param {object} ngIdOrLabel The Network Group ID or Label + * @param {string} networkGroupId The Network Group ID * @param {string} orgaIdOrName The owner ID or name * @returns {Promise>} The Network Groups */ @@ -142,7 +142,7 @@ export async function getAllNGs (orgaIdOrName) { * 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 {boolean} type Look only for a specific type (NetworkGroup, Member, CleverPeer, ExternalPeer, Peer), can be 'single', default to 'all' + * @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 @@ -183,9 +183,9 @@ export async function searchNgOrResource (idOrLabel, orgaIdOrName, type = 'all', filtered = filtered.filter((f) => f.id === query || f.label === query); } - if (filtered.length > 1 && !type === 'all') { + 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')}`); +${filtered.map((f) => ` • ${f.id} ${colors.grey(`(${f.label} - ${f.type})`)}`).join('\n')}`); } // Deduplicate results @@ -195,7 +195,7 @@ ${filtered.map((f) => ` - ${f.id} ${colors.grey(`(${f.label} - ${f.type})`)}`).j /** * Construct members from members_ids * @param {string} ngId The Network Group ID - * @param {Array} members_ids The members IDs + * @param {Array} membersIds The members IDs * @returns {Array} Array of members with id, domainName and kind */ export function constructMembers (ngId, membersIds) { @@ -225,39 +225,45 @@ export function constructMembers (ngId, membersIds) { 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); - const poll = setInterval(async () => { - // We don't use ngApi.getNetworkGroup(), it will lead to an error before creation - const ngs = await ngApi.listNetworkGroups({ ownerId }).then(sendToApi); - const ng = ngs.find((ng) => ng.id === ngId); - - if (waitForDeletion && !ng) { - cleanup(true); + 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; } - 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(', ')}`); - return; - } + try { + const ngs = await ngApi.listNetworkGroups({ ownerId }).then(sendToApi); + const ng = ngs.find((ng) => ng.id === ngId); + + if (waitForDeletion && !ng) { + resolve(); + return; } - cleanup(true); - } - }, INTERVAL); - const timer = setTimeout(() => { - const action = waitForDeletion ? 'deletion of' : 'creation of'; - cleanup(false, new Error(`Timeout while checking ${action} Network Group ${ngId}`)); - }, TIMEOUT * 1000); + 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; + } - function cleanup (success, error = null) { - clearInterval(poll); - clearTimeout(timer); - success ? resolve() : reject(error); + setTimeout(pollOnce, INTERVAL); + } + catch (error) { + reject(error); + } } + + pollOnce(); }); } From 54b73145c2bac203fee95074b96b0647b363716e Mon Sep 17 00:00:00 2001 From: David Legrand <1110600+davlgd@users.noreply.github.com> Date: Mon, 3 Feb 2025 17:52:35 +0100 Subject: [PATCH 10/10] fix: update domain --- docs/ng.md | 2 +- src/models/ng.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ng.md b/docs/ng.md index 7bfb076..7f1865b 100644 --- a/docs/ng.md +++ b/docs/ng.md @@ -20,7 +20,7 @@ When you create a Network Group, a Wireguard configuration is generated with a c 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` +> A Network Group member domain name is composed this way: `.m.cc-ng.cloud` ## Prerequisites diff --git a/src/models/ng.js b/src/models/ng.js index 29db96f..830b507 100644 --- a/src/models/ng.js +++ b/src/models/ng.js @@ -11,7 +11,7 @@ import { v4 as uuidv4 } from 'uuid'; export const TIMEOUT = 30; export const INTERVAL = 1000; -export const DOMAIN = 'ng-cc.cloud'; +export const DOMAIN = 'cc-ng.cloud'; export const TYPE_PREFIXES = { app_: 'APPLICATION', addon_: 'ADDON',