From 9a40ed2e3dd166427aebc4d96b81791c1b8673d8 Mon Sep 17 00:00:00 2001 From: David Legrand Date: Tue, 22 Oct 2024 21:02:04 +0200 Subject: [PATCH 1/6] feat(ng): add feature flag, instructions and tooling Co-Authored-By: kannar --- bin/clever.js | 151 +++++++++++++++- src/commands/ng.js | 322 +++++++++++++++++++++++++++++++++++ src/experimental-features.js | 36 +++- src/models/format-string.js | 32 ++++ src/models/ng.js | 56 ++++++ src/parsers.js | 26 +++ 6 files changed, 614 insertions(+), 9 deletions(-) create mode 100644 src/commands/ng.js create mode 100644 src/models/format-string.js create mode 100644 src/models/ng.js diff --git a/bin/clever.js b/bin/clever.js index 557a0b0b..133eea09 100755 --- a/bin/clever.js +++ b/bin/clever.js @@ -9,6 +9,7 @@ import cliparseCommands from 'cliparse/src/command.js'; import _sortBy from 'lodash/sortBy.js'; import { getPackageJson } from '../src/load-package-json.cjs'; +import * as Formatter from '../src/models/format-string.js'; import * as git from '../src/models/git.js'; import * as Parsers from '../src/parsers.js'; import { handleCommandPromise } from '../src/command-promise-handler.js'; @@ -23,6 +24,7 @@ import * as ApplicationConfiguration from '../src/models/application_configurati import * as Drain from '../src/models/drain.js'; import * as Notification from '../src/models/notification.js'; import * as Namespaces from '../src/models/namespaces.js'; +import * as NetworkGroup from '../src/models/ng.js'; import * as accesslogsModule from '../src/commands/accesslogs.js'; import * as activity from '../src/commands/activity.js'; @@ -44,6 +46,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 +93,67 @@ 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('ng-id', { + description: 'The Network Group ID', + }), + ngLabel: cliparse.argument('label', { + description: 'Network Group label, also used for DNS context', + }), + ngIdOrLabel: cliparse.argument('ng', { + description: 'Network Group ID or label', + parser: Parsers.ngIdOrLabel, + // complete: NetworkGroup('xxx'), + }), + ngDescription: cliparse.argument('description', { + description: 'Network Group description', + }), + ngMemberId: cliparse.argument('member-id', { + description: `The member ID: an app ID (e.g.: ${Formatter.formatCode('app_xxx')}), add-on ID (e.g.: ${Formatter.formatCode('addon_xxx')}) or external node category ID`, + // complete: NetworkGroup('xxx'), + }), + ngMembersIds: cliparse.argument('members-ids', { + description: "Comma separated list of Network Group members IDs ('app_xxx', 'addon_xxx', 'external_xxx')", + parser: Parsers.commaSeparated, + }), + ngMemberDomainName: cliparse.argument('domain-name', { + description: `Member name used in the ${Formatter.formatUrl('.members..ng.clever-cloud.com', false)} domain name alias`, + }), + ngPeerId: cliparse.argument('peer-id', { + description: 'The peer ID', + // complete: NetworkGroup('xxx'), + }), + ngPeerRole: cliparse.argument('role', { + description: `The peer role, (${Formatter.formatString('client')} or ${Formatter.formatString('server')})`, + parser: Parsers.ngPeerRole, + complete: NetworkGroup.listAvailablePeerRoles, + }), + // FIXME: Add "internal" member type + ngMemberType: cliparse.argument('type', { + description: `The member type (${Formatter.formatString('application')}, ${Formatter.formatString('addon')} or ${Formatter.formatString('external')})`, + parser: Parsers.ngMemberType, + complete: NetworkGroup.listAvailableMemberTypes, + }), + ngNodeCategoryId: cliparse.argument('node-category-id', { + description: 'The external node category ID', + // complete: NetworkGroup('xxx'), + }), + ngPeerLabel: cliparse.argument('label', { + description: 'Network Group peer label', + }), + ngPeerParentMemberId: cliparse.argument('parent', { + description: 'Network Group peer category ID (parent member ID)', + // complete: NetworkGroup('xxx'), + }), + ngSearchAppId: cliparse.argument('app-id', { + description: 'The app ID to search', + // complete: NetworkGroup('xxx'), + }), addonIdOrName: cliparse.argument('addon-id', { description: 'Add-on ID (or name, if unambiguous)', parser: Parsers.addonIdOrName, @@ -147,6 +207,21 @@ async function run () { // OPTIONS const opts = { + // Network Groups options + ngMembersIds: cliparse.option('members-ids', { + metavar: 'members_ids', + description: "Comma separated list of Network Group members IDs ('app_xxx', 'addon_xxx', 'external_xxx')", + parser: Parsers.commaSeparated, + }), + ngDescription: cliparse.option('description', { + metavar: 'ng_description', + description: 'Network Group description', + }), + ngMemberLabel: cliparse.option('label', { + required: false, + metavar: 'member_label', + description: 'The member label', + }), sourceableEnvVarsList: cliparse.flag('add-export', { description: 'Display sourceable env variables setting' }), logsFormat: getOutputFormatOption(['json-stream']), activityFormat: getOutputFormatOption(['json-stream']), @@ -746,6 +821,76 @@ async function run () { args: [args.alias], }, makeDefault.makeDefault); + // NETWORK GROUP COMMANDS + const networkGroupsCreateCommand = cliparse.command('create', { + description: 'Create a Network Group', + args: [args.ngLabel], + options: [opts.ngMembersIds, opts.ngDescription, opts.optTags, opts.humanJsonOutputFormat], + }, ng.createNg); + const networkGroupsDeleteCommand = cliparse.command('delete', { + description: 'Delete a Network Group', + args: [args.ngIdOrLabel], + options: [opts.humanJsonOutputFormat], + }, ng.deleteNg); + const networkGroupsListCommand = cliparse.command('list', { + description: 'List available Network Groups with their labels', + options: [opts.humanJsonOutputFormat], + }, ng.listNg); + + const networkGroupsMemberListCommand = cliparse.command('list', { + description: 'List members of a Network Group', + // Add option opts.optNgSearchAppId ? + args: [args.ngIdOrLabel], + options: [opts.naturalName, opts.humanJsonOutputFormat], + }, ng.listMembers); + const networkGroupsMemberGetCommand = cliparse.command('get', { + description: 'Get a Network Group member\'s details', + args: [args.ngIdOrLabel, args.ngMemberId], + options: [opts.naturalName, opts.humanJsonOutputFormat], + }, ng.getMember); + const networkGroupsMemberAddCommand = cliparse.command('add', { + description: 'Add an app or add-on as a Network Group member', + args: [args.ngIdOrLabel, args.ngMemberId], + options: [opts.ngMemberLabel], + }, ng.addMember); + const networkGroupsMemberRemoveCommand = cliparse.command('remove', { + description: 'Remove an app or add-on from a Network Group', + args: [args.ngIdOrLabel, args.ngMemberId], + }, ng.removeMember); + const networkGroupsMembersCategoryCommand = cliparse.command('members', { + description: 'List commands for interacting with Network Group members', + commands: [networkGroupsMemberListCommand, networkGroupsMemberGetCommand, networkGroupsMemberAddCommand, networkGroupsMemberRemoveCommand], + }, ng.listMembers); + + const networkGroupsPeerListCommand = cliparse.command('list', { + description: 'List peers of a Network Group', + args: [args.ngIdOrLabel], + options: [opts.humanJsonOutputFormat], + }, ng.listPeers); + const networkGroupsPeerGetCommand = cliparse.command('get', { + description: 'Get a Network Group peer\'s details', + args: [args.ngIdOrLabel, args.ngPeerId], + options: [opts.humanJsonOutputFormat], + }, ng.getPeer); + /* const networkGroupsPeerAddCommand = cliparse.command('add-external', { + description: 'Add an external node as a Network Group peer', + options: [opts.ngIdOrLabel, opts.ngPeerRole, opts.wgPublicKey, opts.ngPeerLabel, opts.ngPeerParentMemberId], + }, networkgroups('addExternalPeer')); + const networkGroupsPeerRemoveExternalCommand = cliparse.command('remove-external', { + description: 'Remove an external node from a Network Group', + args: [args.ngIdOrLabel, args.ngPeerId], + }, networkgroups('removeExternalPeer')); */ + const networkGroupsPeersCategoryCommand = cliparse.command('peers', { + description: 'List commands for interacting with Network Group peers', + commands: [networkGroupsPeerListCommand, networkGroupsPeerGetCommand], + }, ng.listPeers); + + const networkGroupsCommand = cliparse.command('ng', { + description: 'Manage Network Groups, their members and peers', + options: [opts.orgaIdOrName, opts.alias], + commands: [networkGroupsCreateCommand, networkGroupsDeleteCommand, networkGroupsListCommand, networkGroupsMembersCategoryCommand, networkGroupsPeersCategoryCommand], + }, ng.listNg); + // NOTIFY-EMAIL COMMAND const addEmailNotificationCommand = cliparse.command('add', { description: 'Add a new email notification', @@ -982,6 +1127,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/commands/ng.js b/src/commands/ng.js new file mode 100644 index 00000000..3a9a59b2 --- /dev/null +++ b/src/commands/ng.js @@ -0,0 +1,322 @@ +import { Logger } from '../logger.js'; +import * as NetworkGroup from '../models/ng.js'; +import * as Formatter from '../models/format-string.js'; +import * as ngApi from '@clevercloud/client/cjs/api/v4/network-group.js'; + +import { v4 as uuidv4 } from 'uuid'; +import { sendToApi } from '../models/send-to-api.js'; + +const TIMEOUT = 5000; +const INTERVAL = 500; + +export async function listNg (params) { + const { org: orgaIdOrName, alias, format } = params.options; + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + + Logger.info(`Listing Network Groups from owner ${Formatter.formatString(ownerId)}`); + const result = await ngApi.listNetworkGroups({ ownerId }).then(sendToApi); + Logger.debug(`Received from API: ${JSON.stringify(result, null, 2)}`); + + if (result.length === 0) { + Logger.println(`No Network Group found for ${ownerId}`); + Logger.println(`You can create one with ${Formatter.formatCommand('clever networkgroups create')} command`); + return; + } + + switch (format) { + case 'json': { + Logger.println(JSON.stringify(result, null, 2)); + break; + } + case 'human': + default: { + // We keep only id, label, networkIp, lastAllocatedIp + const ngList = result.map(({ + id, label, networkIp, lastAllocatedIp, members, peers, + }) => ({ + id, label, networkIp, lastAllocatedIp, members: Object.keys(members).length, peers: Object.keys(peers).length, + })); + + console.table(ngList); + } + } +} + +export async function createNg (params) { + const [label] = params.args; + const { org: orgaIdOrName, alias, description, tags, format, 'members-ids': members_ids } = params.options; + + // We generate and set a unique ID to know it before the API call and reuse it later + const ngId = `ng_${uuidv4()}`; + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + + let members = []; + if (members_ids) { + // For each member ID, we add a type depending on the ID format and a domain name + members = members_ids.map((id) => { + + const domainName = `${id}.m.${ngId}.ng.clever-cloud.com`; + const prefixToType = { + app_: 'application', + addon_: 'addon', + external_: 'external', + }; + + const prefix = Object.keys(prefixToType) + .find((p) => id.startsWith(p)); + + let type = prefixToType[prefix]; + if (!type) { + // throw new Error(`Member ID ${Formatter.formatString(id)} is not a valid format. It should start with 'app_', 'addon_' or 'external_'`); + type = 'addon'; + } + return { id, domainName, type }; + }); + } + + const body = { ownerId: ownerId, id: ngId, label, description, tags, members }; + Logger.info(`Creating Network Group ${Formatter.formatString(label)} (${Formatter.formatId(ngId)}) from owner ${Formatter.formatString(ownerId)}`); + Logger.info(`${members.length} members will be added: ${members.map((m) => Formatter.formatString(m.id)).join(', ')}`); + Logger.debug(`Sending body: ${JSON.stringify(body, null, 2)}`); + await ngApi.createNetworkGroup({ ownerId }, body).then(sendToApi); + + // We poll until NG is created to display the result + const polling = setInterval(async () => { + const ng = await ngApi.getNetworkGroup({ ownerId, networkGroupId: ngId }).then(sendToApi).catch(() => { + Logger.error(`Error while fetching Network Group ${Formatter.formatString(ngId)}`); + process.exit(1); + }); + + Logger.debug(`Received from API during polling: ${JSON.stringify(ng, null, 2)}`); + + if (ng.label === label) { + clearInterval(polling); + clearTimeout(timeout); + + const message = format === 'json' + ? JSON.stringify(ng, null, 2) + : `Network Group ${Formatter.formatString(label)} (${Formatter.formatId(ngId)}) has been created successfully`; + + Logger.println(message); + } + }, INTERVAL); + + const timeout = setTimeout(() => { + clearInterval(polling); + Logger.error('Network group creation has been launched asynchronously but timed out. Check the status later with `clever ng list`.'); + }, TIMEOUT); +} + +export async function deleteNg (params) { + const [networkGroupIdOrLabel] = params.args; + const { org: orgaIdOrName, alias } = params.options; + + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); + + Logger.info(`Deleting Network Group ${Formatter.formatString(networkGroupId)} from owner ${Formatter.formatString(ownerId)}`); + await ngApi.deleteNetworkGroup({ ownerId, networkGroupId }).then(sendToApi); + + // We poll until NG is deleted to display the result + const polling = setInterval(async () => { + const ngList = await ngApi.listNetworkGroups({ ownerId }).then(sendToApi); + const ng = ngList.find((ng) => ng.id === networkGroupId); + + Logger.debug(`Received from API during polling: ${JSON.stringify(ng, null, 2)}`); + + if (!ng) { + clearInterval(polling); + clearTimeout(timeout); + + Logger.println(`Network Group ${Formatter.formatString(networkGroupId)} has been deleted successfully`); + } + }, INTERVAL); + + const timeout = setTimeout(() => { + clearInterval(polling); + Logger.error('Network group deletion has been launched asynchronously but timed out. Check the status later with `clever ng list`.'); + }, TIMEOUT); +} + +export async function listMembers (params) { + const [networkGroupIdOrLabel] = params.args; + const { org: orgaIdOrName, alias, 'natural-name': naturalName, format } = params.options; + + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); + + Logger.info(`Listing members from Network Group '${networkGroupId}'`); + Logger.info(naturalName); + + const result = await ngApi.listNetworkGroupMembers({ ownerId, networkGroupId }).then(sendToApi); + Logger.debug(`Received from API: ${JSON.stringify(result, null, 2)}`); + + switch (format) { + case 'json': { + Logger.println(JSON.stringify(result, null, 2)); + break; + } + case 'human': + default: { + if (result.length === 0) { + Logger.println(`No member found. You can add one with ${Formatter.formatCommand('clever networkgroups members add')}.`); + } + else { + const domainNames = result.map((item) => ({ domainName: item.domainName })); + console.table(domainNames); + } + } + } +} + +export async function getMember (params) { + const [networkGroupIdOrLabel, memberId] = params.args; + const { org: orgaIdOrName, alias, 'natural-name': naturalName, format } = params.options; + + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); + + Logger.info(`Getting details for member ${Formatter.formatString(memberId)} in Network Group ${Formatter.formatString(networkGroupId)}`); + Logger.info(`Natural name: ${naturalName}`); + const result = await ngApi.getNetworkGroupMember({ ownerId, networkGroupId, memberId: memberId }).then(sendToApi); + Logger.debug(`Received from API: ${JSON.stringify(result, null, 2)}`); + + switch (format) { + case 'json': { + Logger.println(JSON.stringify(result, null, 2)); + break; + } + case 'human': + default: { + const domainName = [result].map((item) => ({ domainName: item.domainName })); + console.table(domainName); + } + } +} + +export async function addMember (params) { + const [networkGroupIdOrLabel, memberId] = params.args; + const { org: orgaIdOrName, alias, label } = params.options; + + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); + const domainName = `${memberId}.m.${networkGroupId}.ng.clever-cloud.com`; + + let type = null; + if (memberId.startsWith('app_')) { + type = 'application'; + } + else if (memberId.startsWith('addon_')) { + type = 'addon'; + } + else if (memberId.startsWith('external_')) { + type = 'external'; + } + else { + // throw new Error(`Member ID ${Formatter.formatString(memberId)} is not a valid format. It should start with 'app_', 'addon_' or 'external_'`); + type = 'addon'; + } + + const body = { id: memberId, label, domainName: domainName, type }; + Logger.debug('Sending body: ' + JSON.stringify(body, null, 2)); + await ngApi.createNetworkGroupMember({ ownerId, networkGroupId }, body).then(sendToApi); + + Logger.println(`Successfully added member ${Formatter.formatString(memberId)} to Network Group ${Formatter.formatString(networkGroupId)}.`); +} + +export async function removeMember (params) { + const [networkGroupIdOrLabel, memberId] = params.args; + const { org: orgaIdOrName, alias } = params.options; + + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); + + await ngApi.deleteNetworkGroupMember({ ownerId, networkGroupId, memberId }).then(sendToApi); + + Logger.println(`Successfully removed member ${Formatter.formatString(memberId)} from Network Group ${Formatter.formatString(networkGroupId)}.`); +} + +export async function listPeers (params) { + const [networkGroupIdOrLabel] = params.args; + const { org: orgaIdOrName, alias, format } = params.options; + + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); + + Logger.info(`Listing peers from Network Group ${Formatter.formatString(networkGroupId)}`); + const result = await ngApi.listNetworkGroupPeers({ ownerId, networkGroupId }).then(sendToApi); + Logger.debug(`Received from API: ${JSON.stringify(result, null, 2)}`); + + switch (format) { + case 'json': { + Logger.println(JSON.stringify(result, null, 2)); + break; + } + case 'human': + default: { + if (result.length === 0) { + Logger.println(`No peer found. You can add an external one with ${Formatter.formatCommand('clever networkgroups peers add-external')}.`); + } + else { + const peersList = result.map(({ + id, label, endpoint, type, + }) => ({ + id, label, 'host:ip': `${endpoint.ngTerm.host}:${endpoint.ngTerm.port}`, 'peer.type': type, 'endpoint.type': endpoint.type, + })); + console.table(peersList); + } + } + } +} + +export async function getPeer (params) { + const [networkGroupIdOrLabel, peerId] = params.args; + const { org: orgaIdOrName, alias, format } = params.options; + + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); + + Logger.info(`Getting details for peer ${Formatter.formatString(peerId)} in Network Group ${Formatter.formatString(networkGroupId)}`); + const peer = await ngApi.getNetworkGroupPeer({ ownerId, networkGroupId, peerId }).then(sendToApi); + Logger.debug(`Received from API: ${JSON.stringify(peer, null, 2)}`); + + switch (format) { + case 'json': { + Logger.println(JSON.stringify(peer, null, 2)); + break; + } + case 'human': + default: { + // We keep only id, label, 'host:ip': `${endpoint.ngTerm.host}:${endpoint.ngTerm.port}`, type + const peerList = { id: peer.id, label: peer.label, 'host:ip': `${peer.endpoint.ngTerm.host}:${peer.endpoint.ngTerm.port}`, type: peer.type }; + console.table([peerList]); + } + } +} + +export async function addExternalPeer (params) { + const { org: orgaIdOrName, alias, ng: networkGroupIdOrLabel, role, 'public-key': publicKey, label, parent, ip, port } = params.options; + + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); + + const body = { peerRole: role, publicKey: publicKey, label, parentMember: parent, ip, port }; + Logger.info(`Adding external peer to Network Group ${Formatter.formatString(networkGroupId)}`); + Logger.debug('Sending body: ' + JSON.stringify(body, null, 2)); + const { id: peerId } = await ngApi.createNetworkGroupExternalPeer({ ownerId, networkGroupId }, body).then(sendToApi); + + Logger.println(`External peer ${Formatter.formatString(peerId)} must have been added to Network Group ${Formatter.formatString(networkGroupId)}.`); + return peerId; +} + +export async function removeExternalPeer (params) { + const { org: orgaIdOrName, alias, ng: networkGroupIdOrLabel, 'peer-id': peerId } = params.options; + + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); + + Logger.info(`Removing external peer ${Formatter.formatString(peerId)} from Network Group ${Formatter.formatString(networkGroupId)}`); + await ngApi.deleteNetworkGroupExternalPeer({ ownerId, networkGroupId, peerId }).then(sendToApi); + + Logger.println(`External peer ${Formatter.formatString(peerId)} must have been removed from Network Group ${Formatter.formatString(networkGroupId)}.`); +} diff --git a/src/experimental-features.js b/src/experimental-features.js index 4d67f941..a77d1c52 100644 --- a/src/experimental-features.js +++ b/src/experimental-features.js @@ -3,15 +3,35 @@ 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 link 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, add-on, external): + clever ng create myNG --members-ids appId1,appId2 +- Add an application to an existing Network Group: + clever ng add-app myNG myApp +- List Network Groups: + clever ng list +- List Network Groups members: + clever ng members list myNG +- List Network Groups peers (instances of a member): + clever ng peers list myNG +- Delete a Network Group: + clever ng delete myNG + +Learn more about Network Groups: https://github.com/CleverCloud/clever-tools/tree/master/docs/ng.md`, }, }; diff --git a/src/models/format-string.js b/src/models/format-string.js new file mode 100644 index 00000000..128afec0 --- /dev/null +++ b/src/models/format-string.js @@ -0,0 +1,32 @@ +import colors from 'colors/safe.js'; + +export function formatId (id) { + return colors.dim(id); +} + +export function formatString (str, decorated = true) { + const string = decorated ? `'${str}'` : str; + return colors.green(string); +} + +export function formatNumber (number) { + return colors.yellow(number); +} + +export function formatIp (ip) { + return colors.cyan(ip); +} + +export function formatUrl (url, decorated = true) { + const string = decorated ? `<${url}>` : url; + return colors.cyan(string); +} + +export function formatCommand (command, decorated = true) { + const string = decorated ? `\`${command}\`` : command; + return colors.magenta(string); +} + +export function formatCode (code, decorated = true) { + return formatCommand(code, decorated); +} diff --git a/src/models/ng.js b/src/models/ng.js new file mode 100644 index 00000000..bcb95b0b --- /dev/null +++ b/src/models/ng.js @@ -0,0 +1,56 @@ +import * as autocomplete from 'cliparse'; +import * as Organisation from '../models/organisation.js'; +import * as User from '../models/user.js'; +import * as AppConfig from '../models/app_configuration.js'; +import * as ngApi from '@clevercloud/client/cjs/api/v4/network-group.js'; +import { sendToApi } from './send-to-api.js'; + +export async function getOwnerId (orgaIdOrName, alias) { + if (orgaIdOrName == null) { + try { + return (await AppConfig.getAppDetails({ alias })).ownerId; + } + catch (error) { + return (await User.getCurrentId()); + } + } + else { + return (await Organisation.getId(orgaIdOrName)); + } +} + +export async function getId (ownerId, ngIdOrLabel) { + if (ngIdOrLabel == null) { + return null; + } + + if (ngIdOrLabel.ngId != null) { + return ngIdOrLabel.ngId; + } + + return getByLabel(ownerId, ngIdOrLabel.ngLabel) + .then((ng) => ng.id); + +} + +async function getByLabel (ownerId, label) { + const networkGroups = await ngApi.listNetworkGroups({ ownerId }).then(sendToApi); + const filteredNgs = networkGroups.filter((ng) => ng.label === label); + + if (filteredNgs.length === 0) { + throw new Error('Network Group not found'); + } + if (filteredNgs.length > 1) { + throw new Error('Ambiguous Network Group label'); + } + + return filteredNgs[0]; +} + +export function listAvailablePeerRoles () { + return autocomplete.words(['client', 'server']); +} + +export function listAvailableMemberTypes () { + return autocomplete.words(['application', 'addon', 'external']); +} diff --git a/src/parsers.js b/src/parsers.js index c11b5fd1..1bd3fd23 100644 --- a/src/parsers.js +++ b/src/parsers.js @@ -175,3 +175,29 @@ export function durationInSeconds (durationStr = '') { return cliparse.parsers.success(n); } } + +// Network groups parsers +const ngIdRegex = /^ng_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function ngIdOrLabel (string) { + if (string.match(ngIdRegex)) { + return cliparse.parsers.success({ ngId: string }); + } + return cliparse.parsers.success({ ngLabel: string }); +} + +export function ngMemberType (string) { + const possible = ['application', 'addon', 'external']; + if (possible.includes(string)) { + return cliparse.parsers.success(string); + } + return cliparse.parsers.error(`Invalid member type '${string}'. Should be in ${JSON.stringify(possible)}`); +} + +export function ngPeerRole (string) { + const possible = ['client', 'server']; + if (possible.includes(string)) { + return cliparse.parsers.success(string); + } + return cliparse.parsers.error(`Invalid peer role '${string}'. Should be in ${JSON.stringify(possible)}`); +} From 3bf898dab81033b57c909753d77aaab393ca6f6d Mon Sep 17 00:00:00 2001 From: David Legrand Date: Fri, 16 Aug 2024 14:35:23 +0200 Subject: [PATCH 2/6] docs(ng): update initial documentation Co-Authored-By: kannar --- docs/README.md | 1 + docs/networkgroups/commands.md | 167 ------------------ docs/networkgroups/tests.md | 310 --------------------------------- docs/ng.md | 131 ++++++++++++++ 4 files changed, 132 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 21bad15d..1f51d855 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 e60831e1..00000000 --- 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 949aa2d2..00000000 --- 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 00000000..11776fc0 --- /dev/null +++ b/docs/ng.md @@ -0,0 +1,131 @@ +# 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 alpha 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 `type` 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.clever-cloud.com` + +## Prerequisites + +To use Network Groups, you need [Clever Tools installed](/docs/setup-systems.md) in a test version or higher than `4.0.0`. You can check your version with the following command: + +``` +clever version +``` + +To activate the Network Groups feature, you need to create a `clever-tools-features.json` file in your `~/.config/clever-cloud/` directory with the following content: + +```json +{ + "ng": true +} +``` + +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 --members-ids appId1,appId2 +``` + +You can add a description, tags and ask for a JSON output (`--format` or `-F`): + +``` +clever ng create myNG --description "My first NG" --tags test,ng -F json +``` + +## List Network Groups + +Once created, you can list your Network Groups: + +``` +clever ng list + +┌─────────┬───────-┬─────────-─┬───────────────┬─────────────────┬─────────┬───────┐ +| (index) │ id │ label │ networkIp │ lastAllocatedIp │ members │ peers │ +├─────────┼────────┼───────────┼───────────────┼─────────────────┼─────────┼───────┤ +│ 0 │ 'ngId' │ 'ngLabel' │ '10.x.y.z/16' │ '10.x.y.z' │ X │ Y │ +└─────────┴────────┴──────────-┴───────────────┴─────────────────┴─────────┴───────┘ +``` + +A `json` formatted output is available. + +## Delete Network Groups + +You can delete a Network Group through its ID or label: + +``` +clever ng delete ngId +clever ng delete ngLabel +``` + +## Manage members of a Network Group + +To add an application to a Network Group (a `label` is optional): + +``` +clever ng members add ngId appId +clever ng members add ngId appId --label 'member label' +``` + +To get information about members (a `json` formatted output is available): + +``` +clever ng members list ngId_or_ngLabel +clever ng members get ngId_or_ngLabel memberId +``` + +To delete a member from a Network Group: + +``` +clever ng members remove ngId_or_ngLabel memberId +``` + +## Manage peers of a Network Group + +To get information about peers (a `json` formatted output is available): + +``` +clever ng peers list ngId_or_ngLabel +clever ng peers get ngId_or_ngLabel peerId +``` + +## Demos & examples + +You can find ready to deploy projects using Network Groups in the following repositories: + +- XXX + +Create your own and let us know! From 43308a238f6472a8a7013fec36694fde06b56a36 Mon Sep 17 00:00:00 2001 From: David Legrand Date: Sat, 19 Oct 2024 00:09:55 +0200 Subject: [PATCH 3/6] feat(ng): external peers support --- bin/clever.js | 30 +++++++-- src/commands/ng.js | 159 +++++++++++++++++++++++++++++++++++---------- 2 files changed, 149 insertions(+), 40 deletions(-) diff --git a/bin/clever.js b/bin/clever.js index 133eea09..290d11e0 100755 --- a/bin/clever.js +++ b/bin/clever.js @@ -222,6 +222,11 @@ async function run () { metavar: 'member_label', description: 'The member label', }), + wgPublicKey: cliparse.option('public-key', { + required: false, + metavar: 'public_key', + description: 'The public key of the peer', + }), sourceableEnvVarsList: cliparse.flag('add-export', { description: 'Display sourceable env variables setting' }), logsFormat: getOutputFormatOption(['json-stream']), activityFormat: getOutputFormatOption(['json-stream']), @@ -836,6 +841,11 @@ async function run () { description: 'List available Network Groups with their labels', options: [opts.humanJsonOutputFormat], }, ng.listNg); + const networkGroupsGetCommand = cliparse.command('get', { + description: 'Get details about a Network Group', + args: [args.ngIdOrLabel], + options: [opts.humanJsonOutputFormat], + }, ng.getNg); const networkGroupsMemberListCommand = cliparse.command('list', { description: 'List members of a Network Group', @@ -872,23 +882,29 @@ async function run () { args: [args.ngIdOrLabel, args.ngPeerId], options: [opts.humanJsonOutputFormat], }, ng.getPeer); - /* const networkGroupsPeerAddCommand = cliparse.command('add-external', { + const networkGroupsPeerAddExternalCommand = cliparse.command('add-external', { description: 'Add an external node as a Network Group peer', - options: [opts.ngIdOrLabel, opts.ngPeerRole, opts.wgPublicKey, opts.ngPeerLabel, opts.ngPeerParentMemberId], - }, networkgroups('addExternalPeer')); + args: [args.ngIdOrLabel, args.ngPeerLabel, args.ngPeerRole, args.ngPeerParentMemberId], + options: [opts.humanJsonOutputFormat, opts.wgPublicKey], + }, ng.addExternalPeer); const networkGroupsPeerRemoveExternalCommand = cliparse.command('remove-external', { description: 'Remove an external node from a Network Group', args: [args.ngIdOrLabel, args.ngPeerId], - }, networkgroups('removeExternalPeer')); */ + }, ng.removeExternalPeer); + const networkGroupsPeerGetConfigCommand = cliparse.command('get-config', { + description: 'Get the configuration of an external node', + args: [args.ngIdOrLabel, args.ngPeerId], + options: [opts.humanJsonOutputFormat], + }, ng.getExternalPeerConfig); const networkGroupsPeersCategoryCommand = cliparse.command('peers', { description: 'List commands for interacting with Network Group peers', - commands: [networkGroupsPeerListCommand, networkGroupsPeerGetCommand], + commands: [networkGroupsPeerListCommand, networkGroupsPeerGetCommand, networkGroupsPeerAddExternalCommand, networkGroupsPeerRemoveExternalCommand, networkGroupsPeerGetConfigCommand], }, ng.listPeers); const networkGroupsCommand = cliparse.command('ng', { description: 'Manage Network Groups, their members and peers', - options: [opts.orgaIdOrName, opts.alias], - commands: [networkGroupsCreateCommand, networkGroupsDeleteCommand, networkGroupsListCommand, networkGroupsMembersCategoryCommand, networkGroupsPeersCategoryCommand], + options: [opts.orgaIdOrName], + commands: [networkGroupsCreateCommand, networkGroupsDeleteCommand, networkGroupsListCommand, networkGroupsGetCommand, networkGroupsMembersCategoryCommand, networkGroupsPeersCategoryCommand], }, ng.listNg); // NOTIFY-EMAIL COMMAND diff --git a/src/commands/ng.js b/src/commands/ng.js index 3a9a59b2..da70ed3e 100644 --- a/src/commands/ng.js +++ b/src/commands/ng.js @@ -1,3 +1,4 @@ +import { randomBytes } from 'crypto'; import { Logger } from '../logger.js'; import * as NetworkGroup from '../models/ng.js'; import * as Formatter from '../models/format-string.js'; @@ -10,8 +11,8 @@ const TIMEOUT = 5000; const INTERVAL = 500; export async function listNg (params) { - const { org: orgaIdOrName, alias, format } = params.options; - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const { org: orgaIdOrName, format } = params.options; + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); Logger.info(`Listing Network Groups from owner ${Formatter.formatString(ownerId)}`); const result = await ngApi.listNetworkGroups({ ownerId }).then(sendToApi); @@ -42,13 +43,50 @@ export async function listNg (params) { } } +export async function getNg (params) { + const [networkGroupIdOrLabel] = params.args; + const { org: orgaIdOrName, format } = params.options; + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); + const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); + const result = await ngApi.getNetworkGroup({ ownerId, networkGroupId }).then(sendToApi); + Logger.debug(`Received from API: ${JSON.stringify(result, null, 2)}`); + + switch (format) { + case 'json': { + Logger.println(JSON.stringify(result, null, 2)); + break; + } + case 'human': + default: { + const ngData = { + id: result.id, + label: result.label, + description: result.description, + network: `${result.networkIp}`, + 'members/peers': `${Object.keys(result.members).length}/${Object.keys(result.peers).length}`, + }; + + console.table(ngData); + Logger.println(); + + Logger.println('Members:'); + const members = Object.entries(result.members).map(([id, member]) => ({ domainName: member.domainName })); + console.table(members); + + Logger.println('Peers:'); + const peers = Object.entries(result.peers).map(([id, peer]) => ({ parent: peer.parentMember, id: peer.id, label: peer.label, IP: peer.endpoint.ngTerm.host, publicKey: peer.publicKey })); + console.table(peers); + } + } +} + export async function createNg (params) { const [label] = params.args; - const { org: orgaIdOrName, alias, description, tags, format, 'members-ids': members_ids } = params.options; + const { org: orgaIdOrName, description, tags, format, 'members-ids': members_ids } = params.options; // We generate and set a unique ID to know it before the API call and reuse it later const ngId = `ng_${uuidv4()}`; - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); let members = []; if (members_ids) { @@ -109,9 +147,9 @@ export async function createNg (params) { export async function deleteNg (params) { const [networkGroupIdOrLabel] = params.args; - const { org: orgaIdOrName, alias } = params.options; + const { org: orgaIdOrName } = params.options; - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); Logger.info(`Deleting Network Group ${Formatter.formatString(networkGroupId)} from owner ${Formatter.formatString(ownerId)}`); @@ -140,9 +178,9 @@ export async function deleteNg (params) { export async function listMembers (params) { const [networkGroupIdOrLabel] = params.args; - const { org: orgaIdOrName, alias, 'natural-name': naturalName, format } = params.options; + const { org: orgaIdOrName, 'natural-name': naturalName, format } = params.options; - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); Logger.info(`Listing members from Network Group '${networkGroupId}'`); @@ -171,9 +209,9 @@ export async function listMembers (params) { export async function getMember (params) { const [networkGroupIdOrLabel, memberId] = params.args; - const { org: orgaIdOrName, alias, 'natural-name': naturalName, format } = params.options; + const { org: orgaIdOrName, 'natural-name': naturalName, format } = params.options; - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); Logger.info(`Getting details for member ${Formatter.formatString(memberId)} in Network Group ${Formatter.formatString(networkGroupId)}`); @@ -196,9 +234,9 @@ export async function getMember (params) { export async function addMember (params) { const [networkGroupIdOrLabel, memberId] = params.args; - const { org: orgaIdOrName, alias, label } = params.options; + const { org: orgaIdOrName, label } = params.options; - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); const domainName = `${memberId}.m.${networkGroupId}.ng.clever-cloud.com`; @@ -226,9 +264,9 @@ export async function addMember (params) { export async function removeMember (params) { const [networkGroupIdOrLabel, memberId] = params.args; - const { org: orgaIdOrName, alias } = params.options; + const { org: orgaIdOrName } = params.options; - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); await ngApi.deleteNetworkGroupMember({ ownerId, networkGroupId, memberId }).then(sendToApi); @@ -238,9 +276,9 @@ export async function removeMember (params) { export async function listPeers (params) { const [networkGroupIdOrLabel] = params.args; - const { org: orgaIdOrName, alias, format } = params.options; + const { org: orgaIdOrName, format } = params.options; - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); Logger.info(`Listing peers from Network Group ${Formatter.formatString(networkGroupId)}`); @@ -258,12 +296,24 @@ export async function listPeers (params) { Logger.println(`No peer found. You can add an external one with ${Formatter.formatCommand('clever networkgroups peers add-external')}.`); } else { - const peersList = result.map(({ - id, label, endpoint, type, - }) => ({ - id, label, 'host:ip': `${endpoint.ngTerm.host}:${endpoint.ngTerm.port}`, 'peer.type': type, 'endpoint.type': endpoint.type, - })); - console.table(peersList); + for (const peer of result) { + if (peer.endpoint.ngTerm && peer.endpoint.publicTerm) { + peer.ngTerm = `${peer.endpoint.ngTerm.host}:${peer.endpoint.ngTerm.port}`; + peer.publicTerm = `${peer.endpoint.publicTerm.host}:${peer.endpoint.publicTerm.port}`; + delete peer.endpoint; + } + else { + peer.ngIp = peer.endpoint.ngIp; + peer.type = peer.endpoint.type; + delete peer.endpoint; + } + + Logger.println(); + Logger.println(`Peer ${Formatter.formatString(peer.id)}:`); + delete peer.id; + + console.table(peer); + } } } } @@ -271,9 +321,9 @@ export async function listPeers (params) { export async function getPeer (params) { const [networkGroupIdOrLabel, peerId] = params.args; - const { org: orgaIdOrName, alias, format } = params.options; + const { org: orgaIdOrName, format } = params.options; - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); Logger.info(`Getting details for peer ${Formatter.formatString(peerId)} in Network Group ${Formatter.formatString(networkGroupId)}`); @@ -295,28 +345,71 @@ export async function getPeer (params) { } export async function addExternalPeer (params) { - const { org: orgaIdOrName, alias, ng: networkGroupIdOrLabel, role, 'public-key': publicKey, label, parent, ip, port } = params.options; + const { org: orgaIdOrName, format, 'public-key': publicKey } = params.options; + const [networkGroupIdOrLabel, label, role, parent] = params.args; - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); - const body = { peerRole: role, publicKey: publicKey, label, parentMember: parent, ip, port }; + const pk = publicKey || randomBytes(31).toString('base64').replace(/\//g, '-').replace(/\+/g, '_').replace(/=/g, ''); + const body = { peerRole: role, publicKey: pk, label, parentMember: parent }; + // Optional parameters: ip, port, hostname, parentEvent Logger.info(`Adding external peer to Network Group ${Formatter.formatString(networkGroupId)}`); Logger.debug('Sending body: ' + JSON.stringify(body, null, 2)); - const { id: peerId } = await ngApi.createNetworkGroupExternalPeer({ ownerId, networkGroupId }, body).then(sendToApi); + const result = await ngApi.createNetworkGroupExternalPeer({ ownerId, networkGroupId }, body).then(sendToApi); + Logger.debug(`Received from API: ${JSON.stringify(result, null, 2)}`); - Logger.println(`External peer ${Formatter.formatString(peerId)} must have been added to Network Group ${Formatter.formatString(networkGroupId)}.`); - return peerId; + switch (format) { + case 'json': { + Logger.println(JSON.stringify(result, null, 2)); + break; + } + case 'human': + default: { + Logger.println(`External peer ${Formatter.formatString(result.peerId)} have been added to Network Group ${Formatter.formatString(networkGroupId)}`); + } + } } export async function removeExternalPeer (params) { - const { org: orgaIdOrName, alias, ng: networkGroupIdOrLabel, 'peer-id': peerId } = params.options; + const { org: orgaIdOrName } = params.options; + const [networkGroupIdOrLabel, peerId] = params.args; - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName, alias); + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); Logger.info(`Removing external peer ${Formatter.formatString(peerId)} from Network Group ${Formatter.formatString(networkGroupId)}`); await ngApi.deleteNetworkGroupExternalPeer({ ownerId, networkGroupId, peerId }).then(sendToApi); - Logger.println(`External peer ${Formatter.formatString(peerId)} must have been removed from Network Group ${Formatter.formatString(networkGroupId)}.`); + Logger.println(`External peer ${Formatter.formatString(peerId)} have been removed from Network Group ${Formatter.formatString(networkGroupId)}`); +} + +export async function getExternalPeerConfig (params) { + const { org: orgaIdOrName, format } = params.options; + const [networkGroupIdOrLabel, peerId] = params.args; + + const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); + const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); + + Logger.info(`Getting external peer config ${Formatter.formatString(peerId)} from Network Group ${Formatter.formatString(networkGroupId)}`); + const result = await ngApi.getNetworkGroupWireGuardConfiguration({ ownerId, networkGroupId, peerId }).then(sendToApi); + Logger.debug(`Received from API: ${JSON.stringify(result, null, 2)}`); + + const peerToPrint = result.peers.find((peer) => peer.peer_id === peerId); + result.configuration = Buffer.from(result.configuration, 'base64').toString().replace(/\n+/g, '\n'); + + switch (format) { + case 'json': { + Logger.println(JSON.stringify(result, null, 2)); + break; + } + case 'human': + default: { + Logger.println(`Peer ${Formatter.formatString(peerId)}:`); + Logger.println(` - ${peerToPrint.peer_id} (${peerToPrint.peer_ip})`); + Logger.println(` - ${peerToPrint.peer_hostname}`); + Logger.println(); + Logger.println(`Configuration: ${result.configuration}`); + } + } } From 653008d72d522c17e95533be6e48a49c753de00f Mon Sep 17 00:00:00 2001 From: David Legrand Date: Tue, 3 Dec 2024 19:54:32 +0100 Subject: [PATCH 4/6] chore(ng): command refactor --- bin/clever.js | 167 ++++------- src/commands/ng.js | 576 +++++++++++++++--------------------- src/models/format-string.js | 32 -- src/models/ng-ressources.js | 154 ++++++++++ src/models/ng.js | 243 ++++++++++++--- src/parsers.js | 28 +- 6 files changed, 673 insertions(+), 527 deletions(-) delete mode 100644 src/models/format-string.js create mode 100644 src/models/ng-ressources.js diff --git a/bin/clever.js b/bin/clever.js index 290d11e0..5996c129 100755 --- a/bin/clever.js +++ b/bin/clever.js @@ -9,7 +9,6 @@ import cliparseCommands from 'cliparse/src/command.js'; import _sortBy from 'lodash/sortBy.js'; import { getPackageJson } from '../src/load-package-json.cjs'; -import * as Formatter from '../src/models/format-string.js'; import * as git from '../src/models/git.js'; import * as Parsers from '../src/parsers.js'; import { handleCommandPromise } from '../src/command-promise-handler.js'; @@ -24,7 +23,6 @@ import * as ApplicationConfiguration from '../src/models/application_configurati import * as Drain from '../src/models/drain.js'; import * as Notification from '../src/models/notification.js'; import * as Namespaces from '../src/models/namespaces.js'; -import * as NetworkGroup from '../src/models/ng.js'; import * as accesslogsModule from '../src/commands/accesslogs.js'; import * as activity from '../src/commands/activity.js'; @@ -102,57 +100,24 @@ async function run () { ngId: cliparse.argument('ng-id', { description: 'The Network Group ID', }), - ngLabel: cliparse.argument('label', { + ngLabel: cliparse.argument('ng-label', { description: 'Network Group label, also used for DNS context', }), - ngIdOrLabel: cliparse.argument('ng', { + ngIdOrLabel: cliparse.argument('id-or-label', { description: 'Network Group ID or label', parser: Parsers.ngIdOrLabel, - // complete: NetworkGroup('xxx'), }), - ngDescription: cliparse.argument('description', { + ngDescription: cliparse.argument('ng-description', { description: 'Network Group description', }), - ngMemberId: cliparse.argument('member-id', { - description: `The member ID: an app ID (e.g.: ${Formatter.formatCode('app_xxx')}), add-on ID (e.g.: ${Formatter.formatCode('addon_xxx')}) or external node category ID`, - // complete: NetworkGroup('xxx'), - }), - ngMembersIds: cliparse.argument('members-ids', { - description: "Comma separated list of Network Group members IDs ('app_xxx', 'addon_xxx', 'external_xxx')", - parser: Parsers.commaSeparated, - }), - ngMemberDomainName: cliparse.argument('domain-name', { - description: `Member name used in the ${Formatter.formatUrl('.members..ng.clever-cloud.com', false)} domain name alias`, - }), - ngPeerId: cliparse.argument('peer-id', { - description: 'The peer ID', - // complete: NetworkGroup('xxx'), - }), - ngPeerRole: cliparse.argument('role', { - description: `The peer role, (${Formatter.formatString('client')} or ${Formatter.formatString('server')})`, - parser: Parsers.ngPeerRole, - complete: NetworkGroup.listAvailablePeerRoles, - }), - // FIXME: Add "internal" member type - ngMemberType: cliparse.argument('type', { - description: `The member type (${Formatter.formatString('application')}, ${Formatter.formatString('addon')} or ${Formatter.formatString('external')})`, - parser: Parsers.ngMemberType, - complete: NetworkGroup.listAvailableMemberTypes, - }), - ngNodeCategoryId: cliparse.argument('node-category-id', { - description: 'The external node category ID', - // complete: NetworkGroup('xxx'), - }), - ngPeerLabel: cliparse.argument('label', { - description: 'Network Group peer label', - }), - ngPeerParentMemberId: cliparse.argument('parent', { - description: 'Network Group peer category ID (parent member ID)', - // complete: NetworkGroup('xxx'), + ngAnyIdOrLabel: cliparse.argument('id-or-label', { + + description: 'ID or Label of a Network group, a member or an (external) peer', + parser: Parsers.ngRessourceType, }), - ngSearchAppId: cliparse.argument('app-id', { - description: 'The app ID to search', - // complete: NetworkGroup('xxx'), + ngRessourceIdOrLabel: cliparse.argument('id-or-label', { + description: 'ID or Label of Network groups\'s member or (external) peer', + parser: Parsers.ngRessourceType, }), addonIdOrName: cliparse.argument('addon-id', { description: 'Add-on ID (or name, if unambiguous)', @@ -208,6 +173,12 @@ async function run () { // OPTIONS const opts = { // Network Groups options + ngIdOrLabel: cliparse.option('ng', { + metavar: 'ng_id_or_label', + description: 'Network Group ID or label', + parser: Parsers.ngIdOrLabel, + // complete: NetworkGroup('xxx'), + }), ngMembersIds: cliparse.option('members-ids', { metavar: 'members_ids', description: "Comma separated list of Network Group members IDs ('app_xxx', 'addon_xxx', 'external_xxx')", @@ -222,6 +193,9 @@ async function run () { metavar: 'member_label', description: 'The member label', }), + ngPeerGetConfig: cliparse.flag('config', { + description: 'Get the Wireguard configuration of an external node', + }), wgPublicKey: cliparse.option('public-key', { required: false, metavar: 'public_key', @@ -827,84 +801,53 @@ async function run () { }, makeDefault.makeDefault); // NETWORK GROUP COMMANDS - const networkGroupsCreateCommand = cliparse.command('create', { + const ngCreateExternalPeerCommand = cliparse.command('external-peer', { + description: 'Create an external peer in a Network Group', + args: [args.ngRessourceIdOrLabel, args.publicKey, args.ngIdOrLabel], + }, ng.createExternalPeer); + const ngDeleteExternalPeerCommand = cliparse.command('external-peer', { + description: 'Delete an external peer from a Network Group', + args: [args.ngRessourceIdOrLabel, args.ngIdOrLabel], + }, ng.deleteExternalPeer); + const ngCreateCommand = cliparse.command('create', { description: 'Create a Network Group', args: [args.ngLabel], - options: [opts.ngMembersIds, opts.ngDescription, opts.optTags, opts.humanJsonOutputFormat], + privateOptions: [opts.ngMembersIds, opts.ngDescription, opts.optTags], + commands: [ngCreateExternalPeerCommand], }, ng.createNg); - const networkGroupsDeleteCommand = cliparse.command('delete', { + const ngDeleteCommand = cliparse.command('delete', { description: 'Delete a Network Group', args: [args.ngIdOrLabel], - options: [opts.humanJsonOutputFormat], + commands: [ngDeleteExternalPeerCommand], }, ng.deleteNg); - const networkGroupsListCommand = cliparse.command('list', { - description: 'List available Network Groups with their labels', - options: [opts.humanJsonOutputFormat], - }, ng.listNg); - const networkGroupsGetCommand = cliparse.command('get', { - description: 'Get details about a Network Group', - args: [args.ngIdOrLabel], - options: [opts.humanJsonOutputFormat], - }, ng.getNg); - - const networkGroupsMemberListCommand = cliparse.command('list', { - description: 'List members of a Network Group', - // Add option opts.optNgSearchAppId ? - args: [args.ngIdOrLabel], - options: [opts.naturalName, opts.humanJsonOutputFormat], - }, ng.listMembers); - const networkGroupsMemberGetCommand = cliparse.command('get', { - description: 'Get a Network Group member\'s details', - args: [args.ngIdOrLabel, args.ngMemberId], - options: [opts.naturalName, opts.humanJsonOutputFormat], - }, ng.getMember); - const networkGroupsMemberAddCommand = cliparse.command('add', { - description: 'Add an app or add-on as a Network Group member', - args: [args.ngIdOrLabel, args.ngMemberId], + const ngLinkCommand = cliparse.command('link', { + description: 'Link a member or an external peer to a Network Group', + args: [args.ngRessourceIdOrLabel, args.ngIdOrLabel], options: [opts.ngMemberLabel], - }, ng.addMember); - const networkGroupsMemberRemoveCommand = cliparse.command('remove', { - description: 'Remove an app or add-on from a Network Group', - args: [args.ngIdOrLabel, args.ngMemberId], - }, ng.removeMember); - const networkGroupsMembersCategoryCommand = cliparse.command('members', { - description: 'List commands for interacting with Network Group members', - commands: [networkGroupsMemberListCommand, networkGroupsMemberGetCommand, networkGroupsMemberAddCommand, networkGroupsMemberRemoveCommand], - }, ng.listMembers); - - const networkGroupsPeerListCommand = cliparse.command('list', { - description: 'List peers of a Network Group', - args: [args.ngIdOrLabel], + }, ng.linkToNg); + const ngUnlinkCommand = cliparse.command('unlink', { + description: 'Unlink a member or an external peer from a Network Group', + args: [args.ngRessourceIdOrLabel, 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.humanJsonOutputFormat], - }, ng.listPeers); - const networkGroupsPeerGetCommand = cliparse.command('get', { - description: 'Get a Network Group peer\'s details', - args: [args.ngIdOrLabel, args.ngPeerId], + }, ng.printNgOrRessource); + const ngGetConfigCommand = cliparse.command('get-config', { + description: 'Get the Wireguard configuration of a peer', + args: [args.ngRessourceIdOrLabel], options: [opts.humanJsonOutputFormat], - }, ng.getPeer); - const networkGroupsPeerAddExternalCommand = cliparse.command('add-external', { - description: 'Add an external node as a Network Group peer', - args: [args.ngIdOrLabel, args.ngPeerLabel, args.ngPeerRole, args.ngPeerParentMemberId], - options: [opts.humanJsonOutputFormat, opts.wgPublicKey], - }, ng.addExternalPeer); - const networkGroupsPeerRemoveExternalCommand = cliparse.command('remove-external', { - description: 'Remove an external node from a Network Group', - args: [args.ngIdOrLabel, args.ngPeerId], - }, ng.removeExternalPeer); - const networkGroupsPeerGetConfigCommand = cliparse.command('get-config', { - description: 'Get the configuration of an external node', - args: [args.ngIdOrLabel, args.ngPeerId], - options: [opts.humanJsonOutputFormat], - }, ng.getExternalPeerConfig); - const networkGroupsPeersCategoryCommand = cliparse.command('peers', { - description: 'List commands for interacting with Network Group peers', - commands: [networkGroupsPeerListCommand, networkGroupsPeerGetCommand, networkGroupsPeerAddExternalCommand, networkGroupsPeerRemoveExternalCommand, networkGroupsPeerGetConfigCommand], - }, ng.listPeers); - + }, ng.printConfig); + /* const ngJoinCommand = cliparse.command('join', { + description: 'Join a Network Group', + args: [args.ngIdOrLabel], + }, ng.joinNg); */ const networkGroupsCommand = cliparse.command('ng', { - description: 'Manage Network Groups, their members and peers', + description: 'List Network Groups', options: [opts.orgaIdOrName], - commands: [networkGroupsCreateCommand, networkGroupsDeleteCommand, networkGroupsListCommand, networkGroupsGetCommand, networkGroupsMembersCategoryCommand, networkGroupsPeersCategoryCommand], + privateOptions: [opts.humanJsonOutputFormat], + commands: [ngCreateCommand, ngDeleteCommand, ngLinkCommand, ngUnlinkCommand, ngGetCommand, ngGetConfigCommand], }, ng.listNg); // NOTIFY-EMAIL COMMAND diff --git a/src/commands/ng.js b/src/commands/ng.js index da70ed3e..a67660d7 100644 --- a/src/commands/ng.js +++ b/src/commands/ng.js @@ -1,415 +1,327 @@ -import { randomBytes } from 'crypto'; +import colors from 'colors/safe.js'; import { Logger } from '../logger.js'; -import * as NetworkGroup from '../models/ng.js'; -import * as Formatter from '../models/format-string.js'; -import * as ngApi from '@clevercloud/client/cjs/api/v4/network-group.js'; +import * as NG from '../models/ng.js'; +import * as NGRessources from '../models/ng-ressources.js'; + +/** Create a Network Group + * @param {Object} params + * @param {string} params.args[0] Network Group label + * @param {string} params.options.org Organisation ID or name + * @param {string} params.options.description Network Group description + * @param {string} params.options.tags Comma-separated list of tags + * @param {string} params.options.members-ids Comma-separated list of members IDs + */ +export async function createNg (params) { + const [ngLabel] = params.args; + const { org, description, 'members-ids': membersIds, tags } = params.options; -import { v4 as uuidv4 } from 'uuid'; -import { sendToApi } from '../models/send-to-api.js'; + await NG.create(org, ngLabel, description, tags, membersIds); -const TIMEOUT = 5000; -const INTERVAL = 500; + const membersIdsMessage = membersIds ? ` with member(s):\n${colors.grey(` - ${membersIds.join('\n - ')}`)}` : ''; + Logger.println(`${colors.bold.green('✓')} Network Group ${colors.green(ngLabel)} successfully created${membersIdsMessage}!`); +} -export async function listNg (params) { - const { org: orgaIdOrName, format } = params.options; - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); +/** Delete a Network Group + * @param {Object} params + * @param {string} params.args[0] Network Group ID or label + * @param {string} params.options.org Organisation ID or name + */ +export async function deleteNg (params) { + const [ngIdOrLabel] = params.args; + const { org } = params.options; - Logger.info(`Listing Network Groups from owner ${Formatter.formatString(ownerId)}`); - const result = await ngApi.listNetworkGroups({ ownerId }).then(sendToApi); - Logger.debug(`Received from API: ${JSON.stringify(result, null, 2)}`); + await NG.destroy(ngIdOrLabel, org); + Logger.println(`${colors.bold.green('✓')} Network Group ${colors.green(ngIdOrLabel.ngLabel || ngIdOrLabel.ngId)} successfully deleted!`); +} - if (result.length === 0) { - Logger.println(`No Network Group found for ${ownerId}`); - Logger.println(`You can create one with ${Formatter.formatCommand('clever networkgroups create')} command`); - return; +/** Create an external peer in a Network Group + * @param {Object} params + * @param {string} params.args[0] External peer ID or label + * @param {string} params.args[1] Wireguard® public key + * @param {string} params.args[2] Network Group ID or label + * @param {string} params.options.org Organisation ID or name + */ +export async function createExternalPeer (params) { + const [idOrLabel, publicKey, ngIdOrLabel] = params.args; + const { org } = params.options; + + if (!idOrLabel.ngRessourceLabel) { + throw new Error('A valid external peer label is required'); } - switch (format) { - case 'json': { - Logger.println(JSON.stringify(result, null, 2)); - break; - } - case 'human': - default: { - // We keep only id, label, networkIp, lastAllocatedIp - const ngList = result.map(({ - id, label, networkIp, lastAllocatedIp, members, peers, - }) => ({ - id, label, networkIp, lastAllocatedIp, members: Object.keys(members).length, peers: Object.keys(peers).length, - })); - - console.table(ngList); - } + if (!publicKey) { + throw new Error('A Wireguard® public key is required'); } + await NGRessources.createExternalPeerWithParent(ngIdOrLabel, idOrLabel.ngRessourceLabel, publicKey, org); + Logger.println(`${colors.bold.green('✓')} External peer ${colors.green(idOrLabel.ngRessourceLabel)} successfully created in Network Group ${colors.green(ngIdOrLabel.ngLabel || ngIdOrLabel.ngId)}`); } -export async function getNg (params) { - const [networkGroupIdOrLabel] = params.args; - const { org: orgaIdOrName, format } = params.options; - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); - const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); - const result = await ngApi.getNetworkGroup({ ownerId, networkGroupId }).then(sendToApi); - Logger.debug(`Received from API: ${JSON.stringify(result, null, 2)}`); - - switch (format) { - case 'json': { - Logger.println(JSON.stringify(result, null, 2)); - break; - } - case 'human': - default: { - const ngData = { - id: result.id, - label: result.label, - description: result.description, - network: `${result.networkIp}`, - 'members/peers': `${Object.keys(result.members).length}/${Object.keys(result.peers).length}`, - }; - - console.table(ngData); - Logger.println(); - - Logger.println('Members:'); - const members = Object.entries(result.members).map(([id, member]) => ({ domainName: member.domainName })); - console.table(members); - - Logger.println('Peers:'); - const peers = Object.entries(result.peers).map(([id, peer]) => ({ parent: peer.parentMember, id: peer.id, label: peer.label, IP: peer.endpoint.ngTerm.host, publicKey: peer.publicKey })); - console.table(peers); - } - } +/** Delete an external peer from a Network Group + * @param {Object} params + * @param {string} params.args[0] External peer ID or label + * @param {string} params.args[1] Network Group ID or label + * @param {string} params.options.org Organisation ID or name + */ +export async function deleteExternalPeer (params) { + const [ressourceId, ngIdOrLabel] = params.args; + const { org } = params.options; + + await NGRessources.deleteExternalPeerWithParent(ngIdOrLabel, ressourceId.ngRessourceLabel || ressourceId.externalPeerId, org); + Logger.println(`${colors.bold.green('✓')} External peer ${colors.green(ressourceId.ngRessourceLabel || ressourceId.externalPeerId)} successfully deleted from Network Group ${colors.green(ngIdOrLabel.ngLabel || ngIdOrLabel.ngId)}`); } -export async function createNg (params) { - const [label] = params.args; - const { org: orgaIdOrName, description, tags, format, 'members-ids': members_ids } = params.options; - - // We generate and set a unique ID to know it before the API call and reuse it later - const ngId = `ng_${uuidv4()}`; - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); - - let members = []; - if (members_ids) { - // For each member ID, we add a type depending on the ID format and a domain name - members = members_ids.map((id) => { - - const domainName = `${id}.m.${ngId}.ng.clever-cloud.com`; - const prefixToType = { - app_: 'application', - addon_: 'addon', - external_: 'external', - }; - - const prefix = Object.keys(prefixToType) - .find((p) => id.startsWith(p)); - - let type = prefixToType[prefix]; - if (!type) { - // throw new Error(`Member ID ${Formatter.formatString(id)} is not a valid format. It should start with 'app_', 'addon_' or 'external_'`); - type = 'addon'; - } - return { id, domainName, type }; - }); +/** Link a member or an external peer to a Network Group + * @param {Object} params + * @param {string} params.args[0] Member or external peer ID or label + * @param {string} params.args[1] Network Group ID or label + * @param {string} params.options.org Organisation ID or name + * @param {string} params.options['member-label'] Member label + * @throws {Error} If the ressource ID is not a valid member or external peer ID + */ +export async function linkToNg (params) { + const [ressourceId, ngIdOrLabel] = params.args; + const { org, 'member-label': memberLabel } = params.options; + + if (!ressourceId.memberId && !ressourceId.externalPeerId) { + throw new Error(`Ressource ID ${ressourceId} is not a valid member or external peer ID`); } - const body = { ownerId: ownerId, id: ngId, label, description, tags, members }; - Logger.info(`Creating Network Group ${Formatter.formatString(label)} (${Formatter.formatId(ngId)}) from owner ${Formatter.formatString(ownerId)}`); - Logger.info(`${members.length} members will be added: ${members.map((m) => Formatter.formatString(m.id)).join(', ')}`); - Logger.debug(`Sending body: ${JSON.stringify(body, null, 2)}`); - await ngApi.createNetworkGroup({ ownerId }, body).then(sendToApi); - - // We poll until NG is created to display the result - const polling = setInterval(async () => { - const ng = await ngApi.getNetworkGroup({ ownerId, networkGroupId: ngId }).then(sendToApi).catch(() => { - Logger.error(`Error while fetching Network Group ${Formatter.formatString(ngId)}`); - process.exit(1); - }); - - Logger.debug(`Received from API during polling: ${JSON.stringify(ng, null, 2)}`); - - if (ng.label === label) { - clearInterval(polling); - clearTimeout(timeout); - - const message = format === 'json' - ? JSON.stringify(ng, null, 2) - : `Network Group ${Formatter.formatString(label)} (${Formatter.formatId(ngId)}) has been created successfully`; - - Logger.println(message); - } - }, INTERVAL); - - const timeout = setTimeout(() => { - clearInterval(polling); - Logger.error('Network group creation has been launched asynchronously but timed out. Check the status later with `clever ng list`.'); - }, TIMEOUT); + await NGRessources.linkMember(ngIdOrLabel, ressourceId.memberId, org, memberLabel); + Logger.println(`${colors.bold.green('✓')} Member ${colors.green(ressourceId.memberId)} successfully linked to Network Group ${colors.green(ngIdOrLabel.ngLabel || ngIdOrLabel.ngId)}`); } -export async function deleteNg (params) { - const [networkGroupIdOrLabel] = params.args; - const { org: orgaIdOrName } = params.options; - - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); - const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); - - Logger.info(`Deleting Network Group ${Formatter.formatString(networkGroupId)} from owner ${Formatter.formatString(ownerId)}`); - await ngApi.deleteNetworkGroup({ ownerId, networkGroupId }).then(sendToApi); - - // We poll until NG is deleted to display the result - const polling = setInterval(async () => { - const ngList = await ngApi.listNetworkGroups({ ownerId }).then(sendToApi); - const ng = ngList.find((ng) => ng.id === networkGroupId); - - Logger.debug(`Received from API during polling: ${JSON.stringify(ng, null, 2)}`); - - if (!ng) { - clearInterval(polling); - clearTimeout(timeout); - - Logger.println(`Network Group ${Formatter.formatString(networkGroupId)} has been deleted successfully`); - } - }, INTERVAL); +/** Unlink a member or an external peer from a Network Group + * @param {Object} params + * @param {string} params.args[0] Member or external peer ID or label + * @param {string} params.args[1] Network Group ID or label + * @param {string} params.options.org Organisation ID or name + * @throws {Error} If the ressource ID is not a valid member or external peer ID + */ +export async function unlinkFromNg (params) { + const [ressourceId, ngIdOrLabel] = params.args; + const { org } = params.options; + + if (!ressourceId.memberId) { + throw new Error(`Ressource ID ${ressourceId} is not a valid member or external peer ID`); + } - const timeout = setTimeout(() => { - clearInterval(polling); - Logger.error('Network group deletion has been launched asynchronously but timed out. Check the status later with `clever ng list`.'); - }, TIMEOUT); + await NGRessources.unlinkMember(ngIdOrLabel, ressourceId.memberId, org); + Logger.println(`${colors.bold.green('✓')} Member ${colors.green(ressourceId.memberId)} successfully unlinked from Network Group ${colors.green(ngIdOrLabel.ngLabel || ngIdOrLabel.ngId)}`); } -export async function listMembers (params) { - const [networkGroupIdOrLabel] = params.args; - const { org: orgaIdOrName, 'natural-name': naturalName, format } = params.options; - - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); - const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); - - Logger.info(`Listing members from Network Group '${networkGroupId}'`); - Logger.info(naturalName); +/** Print the configuration of a Network Group's peer + * @param {Object} params + * @param {string} params.args[0] Network Group ID or label + * @param {string} params.options.org Organisation ID or name + * @param {string} params.options.format Output format + */ +export async function printConfig (params) { + const [peerIdOrLabel] = params.args; + const { org, format } = params.options; - const result = await ngApi.listNetworkGroupMembers({ ownerId, networkGroupId }).then(sendToApi); - Logger.debug(`Received from API: ${JSON.stringify(result, null, 2)}`); + const config = await NG.getConfig(peerIdOrLabel, org); switch (format) { case 'json': { - Logger.println(JSON.stringify(result, null, 2)); + Logger.printJson(config); break; } case 'human': default: { - if (result.length === 0) { - Logger.println(`No member found. You can add one with ${Formatter.formatCommand('clever networkgroups members add')}.`); - } - else { - const domainNames = result.map((item) => ({ domainName: item.domainName })); - console.table(domainNames); - } + const decodedConfiguration = Buffer.from(config.configuration, 'base64').toString('utf8'); + Logger.println(decodedConfiguration); } } } -export async function getMember (params) { - const [networkGroupIdOrLabel, memberId] = params.args; - const { org: orgaIdOrName, 'natural-name': naturalName, format } = params.options; +/** List Network Groups, their members and peers + * @param {Object} params + * @param {string} 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 ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); - const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); + const ngs = await NG.getNGs(org); - Logger.info(`Getting details for member ${Formatter.formatString(memberId)} in Network Group ${Formatter.formatString(networkGroupId)}`); - Logger.info(`Natural name: ${naturalName}`); - const result = await ngApi.getNetworkGroupMember({ ownerId, networkGroupId, memberId: memberId }).then(sendToApi); - Logger.debug(`Received from API: ${JSON.stringify(result, null, 2)}`); + if (!ngs.length) { + Logger.println(`${colors.blue('!')} No Network Group found, create one with ${colors.blue('clever ng create')} command`); + return; + } switch (format) { case 'json': { - Logger.println(JSON.stringify(result, null, 2)); + Logger.printJson(ngs); break; } case 'human': default: { - const domainName = [result].map((item) => ({ domainName: item.domainName })); - console.table(domainName); + 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); } } } -export async function addMember (params) { - const [networkGroupIdOrLabel, memberId] = params.args; - const { org: orgaIdOrName, label } = params.options; - - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); - const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); - const domainName = `${memberId}.m.${networkGroupId}.ng.clever-cloud.com`; - - let type = null; - if (memberId.startsWith('app_')) { - type = 'application'; - } - else if (memberId.startsWith('addon_')) { - type = 'addon'; - } - else if (memberId.startsWith('external_')) { - type = 'external'; - } - else { - // throw new Error(`Member ID ${Formatter.formatString(memberId)} is not a valid format. It should start with 'app_', 'addon_' or 'external_'`); - type = 'addon'; +/** Print a Network Group, a member or a peer + * @param {Object} params + * @param {string} params.args[0] ID or label of the Network Group, a member or a peer + * @param {string} params.options.org Organisation ID or name + * @param {string} params.options.format Output format + */ +export async function printNgOrRessource (params) { + const [IdOrLabel] = params.args; + const { org, format } = params.options; + + const found = await NG.getNgOrRessource(IdOrLabel, org); + + switch (found.type) { + case 'ng': { + printNG(found.item, format); + break; + } + case 'member': { + printMember(found.item, format); + break; + } + case 'peer': { + printPeer(found.item, format); + break; + } } - - const body = { id: memberId, label, domainName: domainName, type }; - Logger.debug('Sending body: ' + JSON.stringify(body, null, 2)); - await ngApi.createNetworkGroupMember({ ownerId, networkGroupId }, body).then(sendToApi); - - Logger.println(`Successfully added member ${Formatter.formatString(memberId)} to Network Group ${Formatter.formatString(networkGroupId)}.`); -} - -export async function removeMember (params) { - const [networkGroupIdOrLabel, memberId] = params.args; - const { org: orgaIdOrName } = params.options; - - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); - const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); - - await ngApi.deleteNetworkGroupMember({ ownerId, networkGroupId, memberId }).then(sendToApi); - - Logger.println(`Successfully removed member ${Formatter.formatString(memberId)} from Network Group ${Formatter.formatString(networkGroupId)}.`); } -export async function listPeers (params) { - const [networkGroupIdOrLabel] = params.args; - const { org: orgaIdOrName, format } = params.options; - - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); - const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); - - Logger.info(`Listing peers from Network Group ${Formatter.formatString(networkGroupId)}`); - const result = await ngApi.listNetworkGroupPeers({ ownerId, networkGroupId }).then(sendToApi); - Logger.debug(`Received from API: ${JSON.stringify(result, null, 2)}`); +/** Print a Network Group + * @param {Object} ng + * @param {string} format Output format + * @private + */ +function printNG (ng, format) { switch (format) { case 'json': { - Logger.println(JSON.stringify(result, null, 2)); + Logger.printJson(ng); break; } case 'human': default: { - if (result.length === 0) { - Logger.println(`No peer found. You can add an external one with ${Formatter.formatCommand('clever networkgroups peers add-external')}.`); + 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); + + const members = Object.entries(ng.members) + .sort((a, b) => a[1].domainName.localeCompare(b[1].domainName)) + .map(([id, member]) => ({ + 'Domain name': member.domainName, + Peers: member.peers.length, + })); + if (members.length) { + Logger.println(`${colors.bold(' • Members:')}`); + console.table(members); } - else { - for (const peer of result) { - if (peer.endpoint.ngTerm && peer.endpoint.publicTerm) { - peer.ngTerm = `${peer.endpoint.ngTerm.host}:${peer.endpoint.ngTerm.port}`; - peer.publicTerm = `${peer.endpoint.publicTerm.host}:${peer.endpoint.publicTerm.port}`; - delete peer.endpoint; - } - else { - peer.ngIp = peer.endpoint.ngIp; - peer.type = peer.endpoint.type; - delete peer.endpoint; - } - - Logger.println(); - Logger.println(`Peer ${Formatter.formatString(peer.id)}:`); - delete peer.id; - - console.table(peer); - } + + const peers = Object.entries(ng.peers) + .sort((a, b) => a[1].parentMember.localeCompare(b[1].parentMember)) + .map(([id, peer]) => peerToPrint(peer)); + if (peers.length) { + Logger.println(`${colors.bold(' • Peers:')}`); + console.table(peers); } } } } -export async function getPeer (params) { - const [networkGroupIdOrLabel, peerId] = params.args; - const { org: orgaIdOrName, format } = params.options; - - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); - const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); - - Logger.info(`Getting details for peer ${Formatter.formatString(peerId)} in Network Group ${Formatter.formatString(networkGroupId)}`); - const peer = await ngApi.getNetworkGroupPeer({ ownerId, networkGroupId, peerId }).then(sendToApi); - Logger.debug(`Received from API: ${JSON.stringify(peer, null, 2)}`); - +/** Print a Network Group member + * @param {Object} member + * @param {string} format Output format + */ +function printMember (member, format) { switch (format) { case 'json': { - Logger.println(JSON.stringify(peer, null, 2)); + Logger.println(JSON.stringify(member, null, 2)); break; } case 'human': default: { - // We keep only id, label, 'host:ip': `${endpoint.ngTerm.host}:${endpoint.ngTerm.port}`, type - const peerList = { id: peer.id, label: peer.label, 'host:ip': `${peer.endpoint.ngTerm.host}:${peer.endpoint.ngTerm.port}`, type: peer.type }; - console.table([peerList]); + const itemsToPrint = [member] + .map((item) => ({ + 'Domain name': item.domainName, + Peers: item.peers.length, + })); + console.table(itemsToPrint); + + const peers = member.peers + .sort((a, b) => a.parentMember.localeCompare(b.parentMember)) + .map((peer) => peerToPrint(peer)); + if (peers.length) { + Logger.println('Peers:'); + console.table(peers); + } } } } -export async function addExternalPeer (params) { - const { org: orgaIdOrName, format, 'public-key': publicKey } = params.options; - const [networkGroupIdOrLabel, label, role, parent] = params.args; - - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); - const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); - - const pk = publicKey || randomBytes(31).toString('base64').replace(/\//g, '-').replace(/\+/g, '_').replace(/=/g, ''); - const body = { peerRole: role, publicKey: pk, label, parentMember: parent }; - // Optional parameters: ip, port, hostname, parentEvent - Logger.info(`Adding external peer to Network Group ${Formatter.formatString(networkGroupId)}`); - Logger.debug('Sending body: ' + JSON.stringify(body, null, 2)); - const result = await ngApi.createNetworkGroupExternalPeer({ ownerId, networkGroupId }, body).then(sendToApi); - Logger.debug(`Received from API: ${JSON.stringify(result, null, 2)}`); - +/** Print a Network Group peer + * @param {Object} peer + * @param {string} format Output format + * @param {boolean} full Print full peer details + * @private + */ +function printPeer (peer, format, full = false) { switch (format) { case 'json': { - Logger.println(JSON.stringify(result, null, 2)); + Logger.println(JSON.stringify(peer, null, 2)); break; } case 'human': default: { - Logger.println(`External peer ${Formatter.formatString(result.peerId)} have been added to Network Group ${Formatter.formatString(networkGroupId)}`); + console.table(peerToPrint(peer, full)); } } } -export async function removeExternalPeer (params) { - const { org: orgaIdOrName } = params.options; - const [networkGroupIdOrLabel, peerId] = params.args; - - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); - const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); - - Logger.info(`Removing external peer ${Formatter.formatString(peerId)} from Network Group ${Formatter.formatString(networkGroupId)}`); - await ngApi.deleteNetworkGroupExternalPeer({ ownerId, networkGroupId, peerId }).then(sendToApi); - - Logger.println(`External peer ${Formatter.formatString(peerId)} have been removed from Network Group ${Formatter.formatString(networkGroupId)}`); -} - -export async function getExternalPeerConfig (params) { - const { org: orgaIdOrName, format } = params.options; - const [networkGroupIdOrLabel, peerId] = params.args; - - const ownerId = await NetworkGroup.getOwnerId(orgaIdOrName); - const networkGroupId = await NetworkGroup.getId(ownerId, networkGroupIdOrLabel); - - Logger.info(`Getting external peer config ${Formatter.formatString(peerId)} from Network Group ${Formatter.formatString(networkGroupId)}`); - const result = await ngApi.getNetworkGroupWireGuardConfiguration({ ownerId, networkGroupId, peerId }).then(sendToApi); - Logger.debug(`Received from API: ${JSON.stringify(result, null, 2)}`); - - const peerToPrint = result.peers.find((peer) => peer.peer_id === peerId); - result.configuration = Buffer.from(result.configuration, 'base64').toString().replace(/\n+/g, '\n'); - - switch (format) { - case 'json': { - Logger.println(JSON.stringify(result, null, 2)); - break; - } - case 'human': - default: { - Logger.println(`Peer ${Formatter.formatString(peerId)}:`); - Logger.println(` - ${peerToPrint.peer_id} (${peerToPrint.peer_ip})`); - Logger.println(` - ${peerToPrint.peer_hostname}`); - Logger.println(); - Logger.println(`Configuration: ${result.configuration}`); - } +/** Print a Network Group peer + * @param {Object} peer + * @param {boolean} full Print more peer details + * @private + */ +function peerToPrint (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; } diff --git a/src/models/format-string.js b/src/models/format-string.js deleted file mode 100644 index 128afec0..00000000 --- a/src/models/format-string.js +++ /dev/null @@ -1,32 +0,0 @@ -import colors from 'colors/safe.js'; - -export function formatId (id) { - return colors.dim(id); -} - -export function formatString (str, decorated = true) { - const string = decorated ? `'${str}'` : str; - return colors.green(string); -} - -export function formatNumber (number) { - return colors.yellow(number); -} - -export function formatIp (ip) { - return colors.cyan(ip); -} - -export function formatUrl (url, decorated = true) { - const string = decorated ? `<${url}>` : url; - return colors.cyan(string); -} - -export function formatCommand (command, decorated = true) { - const string = decorated ? `\`${command}\`` : command; - return colors.magenta(string); -} - -export function formatCode (code, decorated = true) { - return formatCommand(code, decorated); -} diff --git a/src/models/ng-ressources.js b/src/models/ng-ressources.js new file mode 100644 index 00000000..4072d729 --- /dev/null +++ b/src/models/ng-ressources.js @@ -0,0 +1,154 @@ +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'; + +const TIMEOUT = 30; +const INTERVAL = 1000; + +export async function linkMember (ngIdOrLabel, ressourceId, org, label) { + const found = await NG.getNgOrRessource(ngIdOrLabel, org); + + if (found.type !== 'ng') { + throw new Error(`${ngIdOrLabel} is not a Network Group`); + } + const [member] = NG.constructMembers(found.item.id, [ressourceId]); + + const body = { + id: member.id, + label: label || member.id, + domainName: member.domainName, + type: member.type, + }; + + Logger.info(`Linking member ${member.id} to Network Group ${found.item.id}`); + Logger.debug('Sending body: ' + JSON.stringify(body, null, 2)); + await ngApi.createNetworkGroupMember({ ownerId: found.item.ownerId, networkGroupId: found.item.id }, body).then(sendToApi); + await waitForResourceStatus( + org, + (ng) => ng.members.some((m) => m.id === member.id), + ); + Logger.info(`Member ${member.id} linked to Network Group ${found.item.id}`); +} + +export async function unlinkMember (ngIdOrLabel, memberId, org) { + const found = await NG.getNgOrRessource(ngIdOrLabel, org); + + if (found.type !== 'ng') { + throw new Error(`${ngIdOrLabel} is not a Network Group`); + } + + Logger.info(`Unlinking member ${memberId} from Network Group ${found.item.id}`); + await ngApi.deleteNetworkGroupMember({ ownerId: found.item.ownerId, networkGroupId: found.item.id, memberId }).then(sendToApi); + await waitForResourceStatus( + org, + (ng) => !ng.members.some((m) => m.id === memberId), + ); + Logger.info(`Member ${memberId} unlinked from Network Group ${found.item.id}`); +} + +export async function createExternalPeerWithParent (ngIdOrLabel, label, publicKey, org) { + const found = await NG.getNgOrRessource(ngIdOrLabel, org); + + if (found.type !== 'ng') { + throw new Error(`${ngIdOrLabel} is not a Network Group`); + } + + // For now, we create and link a parent member to use it for the external peer + const parentMember = { + id: `external_${uuidv4()}`, + label: `Parent of ${label}`, + domainName: `${label}.m.${found.item.id}.${NG.DOMAIN}`, + type: 'external', + }; + + Logger.info(`Creating a parent member ${parentMember.id} linked to Network Group ${found.item.id}`); + await linkMember(found.item.id, parentMember.id, org, parentMember.label); + await waitForResourceStatus( + org, + (ng) => !ng.members.some((m) => m.id === parentMember.id), + ); + Logger.info(`Parent member ${parentMember.id} created and linked to Network Group ${found.item.id}`); + + // For now we only support client role + const body = { + peerRole: 'client', + publicKey, + label, + parentMember: parentMember.id, + }; + + Logger.info(`Adding external peer to Member ${parentMember.id} of Network Group ${found.item.id}`); + Logger.debug('Sending body: ' + JSON.stringify(body, null, 2)); + await ngApi.createNetworkGroupExternalPeer({ ownerId: found.item.ownerId, networkGroupId: found.item.id }, body).then(sendToApi); + + await waitForResourceStatus( + org, + (ng) => ng.peers.some((p) => p.label === label), + ); + + Logger.info(`External peer ${label} added to Member ${parentMember.id} of Network Group ${found.item.id}`); +} + +/** + * Delete an external peer and its parent member from a Network Group + * @param {string} ngIdOrLabel Network Group ID or label + * @param {string} label External peer label + * @param {string} org Organisation ID or name + */ +export async function deleteExternalPeerWithParent (ngIdOrLabel, label, org) { + const found = await NG.getNgOrRessource(ngIdOrLabel, org); + + if (found.type !== 'ng') { + throw new Error(`${ngIdOrLabel} is not a Network Group`); + } + + const peer = found.item.peers.find((p) => p.label === label); + + if (!peer) { + throw new Error(`Peer ${label} not found`); + } + + const body = { + ownerId: found.item.ownerId, + networkGroupId: found.item.id, + peerId: peer.id, + }; + + Logger.info(`Deleting external peer ${peer.id} from Network Group ${found.item.id}`); + Logger.debug('Sending body: ' + JSON.stringify(body, null, 2)); + await ngApi.deleteNetworkGroupExternalPeer(body).then(sendToApi); + await waitForResourceStatus( + org, + (ng) => ng.peers.some((p) => p.id === peer.id), + ); + Logger.info(`External peer ${peer.id} deleted from Network Group ${found.item.id}`); + + Logger.info(`Unlinking parent member ${peer.parentMember} from Network Group ${found.item.id}`); + await unlinkMember(ngIdOrLabel, peer.parentMember, org); + await waitForResourceStatus( + org, + (ng) => ng.members.some((m) => m.id === peer.parentMember), + ); + Logger.info(`Parent member ${peer.parentMember} unlinked from Network Group ${found.item.id}`); +} + +/** + * Wait for a resource to reach a specific status + * @param {string} org Organisation ID or name + * @param {Function} statusCheckFn Status check function + * @param {number} timeout Timeout in seconds + * @param {number} interval Interval in milliseconds + */ +async function waitForResourceStatus (org, statusCheckFn, timeout = TIMEOUT, interval = INTERVAL) { + const startTime = Date.now(); + while (Date.now() - startTime < timeout * 1000) { + const ngs = await NG.getNGs(org); + if (!ngs.some(statusCheckFn)) { + break; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } +} diff --git a/src/models/ng.js b/src/models/ng.js index bcb95b0b..56ee8f32 100644 --- a/src/models/ng.js +++ b/src/models/ng.js @@ -1,56 +1,229 @@ -import * as autocomplete from 'cliparse'; -import * as Organisation from '../models/organisation.js'; +import colors from 'colors/safe.js'; import * as User from '../models/user.js'; -import * as AppConfig from '../models/app_configuration.js'; +import * as Organisation from '../models/organisation.js'; import * as ngApi from '@clevercloud/client/cjs/api/v4/network-group.js'; + import { sendToApi } from './send-to-api.js'; +import { Logger } from '../logger.js'; +import { v4 as uuidv4 } from 'uuid'; -export async function getOwnerId (orgaIdOrName, alias) { - if (orgaIdOrName == null) { - try { - return (await AppConfig.getAppDetails({ alias })).ownerId; - } - catch (error) { - return (await User.getCurrentId()); - } - } - else { - return (await Organisation.getId(orgaIdOrName)); - } +export const TIMEOUT = 5000; +export const INTERVAL = 500; +export const DOMAIN = 'ng-cc.cloud'; +export const TYPE_PREFIXES = { + app_: 'application', + addon_: 'addon', + external_: 'external', +}; + +/** + * Construct members from members_ids + * @param {Array} members_ids + * @returns {Array} Array of members with id, domainName and type + */ +export function constructMembers (ngId, membersIds) { + return membersIds.map((id) => { + const domainName = `${id}.m.${ngId}.${DOMAIN}`; + const prefixToType = TYPE_PREFIXES; + + return { + id, + domainName, + // Get type from prefix match in id (app_*, addon_*, external_*) or default to 'application' + type: prefixToType[Object.keys(prefixToType).find((p) => id.startsWith(p))] + || 'application', + }; + }); +} + +/** + * Ask for a Network Group creation + * @param {string} orgaIdOrName The owner ID or name + * @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 Network Group members IDs + */ +export async function create (orgaIdOrName, label, description, tags, membersIds) { + const id = `ng_${uuidv4()}`; + const ownerId = (orgaIdOrName != null) + ? await Organisation.getId(orgaIdOrName) + : await User.getCurrentId(); + + 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}`); + return { id, ownerId }; } -export async function getId (ownerId, ngIdOrLabel) { - if (ngIdOrLabel == null) { - return null; +export async function destroy (ngIdOrLabel, orgaIdOrName) { + const found = await getNgOrRessource(ngIdOrLabel, orgaIdOrName); + + if (found.type !== 'ng') { + throw new Error('You need to specify a Network Group ID or label'); } - if (ngIdOrLabel.ngId != null) { - return ngIdOrLabel.ngId; + await ngApi.deleteNetworkGroup({ ownerId: found.item.ownerId, networkGroupId: found.item.id }).then(sendToApi); + Logger.info(`Deleting Network Group ${found.item.id} from owner ${found.item.ownerId}`); + await pollNetworkGroup(found.item.ownerId, found.item.id, { waitForDeletion: true }); + Logger.info(`Network Group ${found.item.id} deleted from owner ${found.item.ownerId}`); +} + +export async function getConfig (ngIdOrLabel, orgaIdOrName) { + const found = await getNgOrRessource(ngIdOrLabel, orgaIdOrName); + + if (found.type !== 'peer') { + throw new Error('You need to specify a Peer ID or label'); } - return getByLabel(ownerId, ngIdOrLabel.ngLabel) - .then((ng) => ng.id); + Logger.debug(`Getting configuration for Network Group ${found.item.id} of owner ${found.ownerId}`); + const result = await ngApi.getNetworkGroupWireGuardConfiguration({ + ownerId: found.ownerId, + networkGroupId: found.parentNgId, + peerId: found.item.id, + }).then(sendToApi); + Logger.debug(`Received from API:\n${JSON.stringify(result, null, 2)}`); + return result; } -async function getByLabel (ownerId, label) { - const networkGroups = await ngApi.listNetworkGroups({ ownerId }).then(sendToApi); - const filteredNgs = networkGroups.filter((ng) => ng.label === label); +/** + * Get Network Groups from an owner + * @param {string} orgaIdOrName The owner ID or name + * @returns {Promise>} The Network Groups + */ +export async function getNGs (orgaIdOrName) { + const ownerId = (orgaIdOrName != null) + ? await Organisation.getId(orgaIdOrName) + : await User.getCurrentId(); + + 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; +} - if (filteredNgs.length === 0) { - throw new Error('Network Group not found'); +/** + * Get a Network Group or a member/peer + * @param {string|Object} IdOrLabel The Network Group ID or label, or a member/peer ID or label + * @param {string} orgaIdOrName The owner ID or name + * @throws {Error} If no Network Group or member/peer is found + * @throws {Error} If multiple Network Groups or member/peer are found + * @returns {Promise} The Network Group or a member/peer { type: 'ng' | 'member' | 'peer', item: Object, ownerId: string, parentNgId: string } + + */ +export async function getNgOrRessource (IdOrLabel, orgaIdOrName) { + const ngs = await getNGs(orgaIdOrName); + + let searchString = typeof IdOrLabel === 'string' ? IdOrLabel : null; + if (!searchString) { + searchString = IdOrLabel.ngId + || IdOrLabel.ngLabel + || IdOrLabel.memberId + || IdOrLabel.peerId + || IdOrLabel.externalPeerId + || IdOrLabel.ngRessourceLabel; } - if (filteredNgs.length > 1) { - throw new Error('Ambiguous Network Group label'); + + const foundItems = ngs.reduce((results, ng) => { + if (ng.id === searchString || ng.label === searchString) { + Logger.debug(`Found Network Group ${ng.id}`); + results.push({ type: 'ng', item: ng }); + } + + const items = [ + ...ng.members.map((m) => ({ type: 'member', parentNgId: ng.id, ownerId: ng.ownerId, item: m })), + ...ng.peers.map((p) => ({ type: 'peer', parentNgId: ng.id, ownerId: ng.ownerId, item: p })), + ]; + + // TODO: Move this to the API response + // We add the peers to the corresponding members + ng.members.forEach((member) => { + member.peers = ng.peers.filter((peer) => peer.parentMember === member.id); + }); + + items.forEach((found) => { + if (found.item.id === searchString || found.item.label === searchString) { + Logger.debug(`Found ${found.type} ${found.item.id} in Network Group ${ng.id}`); + results.push(found); + } + }); + + return results; + }, []); + + Logger.debug(`Found items:\n${JSON.stringify(foundItems, null, 2)}`); + + if (foundItems.length === 0) { + throw new Error(`No network group or resource found for ${colors.red(searchString)}`); } - return filteredNgs[0]; -} + // We deduplicate results, as same ID/Label can be found in multiple Network Groups + const deduplicatedFoundItems = foundItems.filter((item, index, self) => + index === self.findIndex((t) => t.item.id === item.item.id && t.item.label === item.item.label), + ); + + if (deduplicatedFoundItems.length > 1) { + throw new Error(`Multiple ressources found for ${colors.red(searchString)}, use ID instead: +${foundItems.map((found) => colors.grey(` - ${found.item.id} (${found.item.label} - ${found.type} - ${found.parentNgId})`)).join('\n')}`); + } -export function listAvailablePeerRoles () { - return autocomplete.words(['client', 'server']); + return deduplicatedFoundItems[0]; } -export function listAvailableMemberTypes () { - return autocomplete.words(['application', 'addon', 'external']); +/** + * 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 () => { + const ngs = await ngApi.listNetworkGroups({ ownerId }).then(sendToApi); + const ng = ngs.find((ng) => ng.id === ngId); + + // Pour la suppression, on attend que le NG ne soit plus présent + if (waitForDeletion && !ng) { + cleanup(true); + return; + } + + // Pour la création, on attend que le NG soit présent + if (!waitForDeletion && ng) { + // Si on attend des membres spécifiques + 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); + + function cleanup (success, error = null) { + clearInterval(poll); + clearTimeout(timer); + success ? resolve() : reject(error); + } + }); } diff --git a/src/parsers.js b/src/parsers.js index 1bd3fd23..3e9267b2 100644 --- a/src/parsers.js +++ b/src/parsers.js @@ -1,5 +1,4 @@ import cliparse from 'cliparse'; - import * as Application from './models/application.js'; import ISO8601 from 'iso8601-duration'; import Duration from 'duration-js'; @@ -177,27 +176,24 @@ export function durationInSeconds (durationStr = '') { } // Network groups parsers -const ngIdRegex = /^ng_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - export function ngIdOrLabel (string) { - if (string.match(ngIdRegex)) { + if (string.startsWith('ng_')) { return cliparse.parsers.success({ ngId: string }); } return cliparse.parsers.success({ ngLabel: string }); } -export function ngMemberType (string) { - const possible = ['application', 'addon', 'external']; - if (possible.includes(string)) { - return cliparse.parsers.success(string); - } - return cliparse.parsers.error(`Invalid member type '${string}'. Should be in ${JSON.stringify(possible)}`); -} +const externalIdRegex = /^external_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -export function ngPeerRole (string) { - const possible = ['client', 'server']; - if (possible.includes(string)) { - return cliparse.parsers.success(string); +export function ngRessourceType (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 }); + } + if (string.match(externalIdRegex)) { + return cliparse.parsers.success({ peerId: string }); } - return cliparse.parsers.error(`Invalid peer role '${string}'. Should be in ${JSON.stringify(possible)}`); + return cliparse.parsers.success({ ngRessourceLabel: string }); } From 0c9ae96a9d3302584d75256666443947be96d678 Mon Sep 17 00:00:00 2001 From: David Legrand <1110600+davlgd@users.noreply.github.com> Date: Fri, 10 Jan 2025 16:45:38 +0100 Subject: [PATCH 5/6] chore: type > kind --- src/models/ng-ressources.js | 4 ++-- src/models/ng.js | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/models/ng-ressources.js b/src/models/ng-ressources.js index 4072d729..02bbb8f2 100644 --- a/src/models/ng-ressources.js +++ b/src/models/ng-ressources.js @@ -20,7 +20,7 @@ export async function linkMember (ngIdOrLabel, ressourceId, org, label) { id: member.id, label: label || member.id, domainName: member.domainName, - type: member.type, + kind: member.kind, }; Logger.info(`Linking member ${member.id} to Network Group ${found.item.id}`); @@ -61,7 +61,7 @@ export async function createExternalPeerWithParent (ngIdOrLabel, label, publicKe id: `external_${uuidv4()}`, label: `Parent of ${label}`, domainName: `${label}.m.${found.item.id}.${NG.DOMAIN}`, - type: 'external', + kind: 'EXTERNAL', }; Logger.info(`Creating a parent member ${parentMember.id} linked to Network Group ${found.item.id}`); diff --git a/src/models/ng.js b/src/models/ng.js index 56ee8f32..5abc7a6d 100644 --- a/src/models/ng.js +++ b/src/models/ng.js @@ -11,15 +11,15 @@ export const TIMEOUT = 5000; export const INTERVAL = 500; export const DOMAIN = 'ng-cc.cloud'; export const TYPE_PREFIXES = { - app_: 'application', - addon_: 'addon', - external_: 'external', + app_: 'APPLICATION', + addon_: 'ADDON', + external_: 'EXTERNAL', }; /** * Construct members from members_ids * @param {Array} members_ids - * @returns {Array} Array of members with id, domainName and type + * @returns {Array} Array of members with id, domainName and kind */ export function constructMembers (ngId, membersIds) { return membersIds.map((id) => { @@ -29,9 +29,9 @@ export function constructMembers (ngId, membersIds) { return { id, domainName, - // Get type from prefix match in id (app_*, addon_*, external_*) or default to 'application' - type: prefixToType[Object.keys(prefixToType).find((p) => id.startsWith(p))] - || 'application', + // 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_, }; }); } From a1a97368f360db15694d3b273518a6e5032d787e Mon Sep 17 00:00:00 2001 From: David Legrand <1110600+davlgd@users.noreply.github.com> Date: Fri, 10 Jan 2025 17:21:03 +0100 Subject: [PATCH 6/6] docs: adapt to new commands --- bin/clever.js | 7 ++-- docs/ng.md | 81 ++++++++++++++++-------------------- src/commands/ng.js | 4 +- src/experimental-features.js | 27 ++++++------ 4 files changed, 55 insertions(+), 64 deletions(-) diff --git a/bin/clever.js b/bin/clever.js index 5996c129..92c1f34f 100755 --- a/bin/clever.js +++ b/bin/clever.js @@ -801,10 +801,10 @@ async function run () { }, makeDefault.makeDefault); // NETWORK GROUP COMMANDS - const ngCreateExternalPeerCommand = cliparse.command('external-peer', { + /* const ngCreateExternalPeerCommand = cliparse.command('external-peer', { description: 'Create an external peer in a Network Group', args: [args.ngRessourceIdOrLabel, args.publicKey, args.ngIdOrLabel], - }, ng.createExternalPeer); + }, ng.createExternalPeer); */ const ngDeleteExternalPeerCommand = cliparse.command('external-peer', { description: 'Delete an external peer from a Network Group', args: [args.ngRessourceIdOrLabel, args.ngIdOrLabel], @@ -813,7 +813,7 @@ async function run () { description: 'Create a Network Group', args: [args.ngLabel], privateOptions: [opts.ngMembersIds, opts.ngDescription, opts.optTags], - commands: [ngCreateExternalPeerCommand], + // commands: [ngCreateExternalPeerCommand], }, ng.createNg); const ngDeleteCommand = cliparse.command('delete', { description: 'Delete a Network Group', @@ -823,7 +823,6 @@ async function run () { const ngLinkCommand = cliparse.command('link', { description: 'Link a member or an external peer to a Network Group', args: [args.ngRessourceIdOrLabel, args.ngIdOrLabel], - options: [opts.ngMemberLabel], }, ng.linkToNg); const ngUnlinkCommand = cliparse.command('unlink', { description: 'Unlink a member or an external peer from a Network Group', diff --git a/docs/ng.md b/docs/ng.md index 11776fc0..14cb60a1 100644 --- a/docs/ng.md +++ b/docs/ng.md @@ -2,8 +2,8 @@ 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`) +* 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`. @@ -20,22 +20,16 @@ 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.clever-cloud.com` +> A Network Group member domain name is composed this way: `.m..ng-cc.cloud` ## Prerequisites -To use Network Groups, you need [Clever Tools installed](/docs/setup-systems.md) in a test version or higher than `4.0.0`. You can check your version with the following command: +To use Network Groups, you need [an alpha release of Clever Tools](https://github.com/CleverCloud/clever-tools/pull/780). -``` -clever version -``` - -To activate the Network Groups feature, you need to create a `clever-tools-features.json` file in your `~/.config/clever-cloud/` directory with the following content: +Activate `ng` feature flag to manage Network Groups: -```json -{ - "ng": true -} +``` +clever features enable ng ``` Then, check it works with the following command: @@ -57,31 +51,15 @@ clever ng create myNG You can create it declaring its members: ``` -clever ng create myNG --members-ids appId1,appId2 -``` - -You can add a description, tags and ask for a JSON output (`--format` or `-F`): - -``` -clever ng create myNG --description "My first NG" --tags test,ng -F json +clever ng create myNG --members-ids appId,addonId,externalId ``` -## List Network Groups - -Once created, you can list your Network Groups: +You can add a description and tags: ``` -clever ng list - -┌─────────┬───────-┬─────────-─┬───────────────┬─────────────────┬─────────┬───────┐ -| (index) │ id │ label │ networkIp │ lastAllocatedIp │ members │ peers │ -├─────────┼────────┼───────────┼───────────────┼─────────────────┼─────────┼───────┤ -│ 0 │ 'ngId' │ 'ngLabel' │ '10.x.y.z/16' │ '10.x.y.z' │ X │ Y │ -└─────────┴────────┴──────────-┴───────────────┴─────────────────┴─────────┴───────┘ +clever ng create myNG --description "My first NG" --tags test,ng ``` -A `json` formatted output is available. - ## Delete Network Groups You can delete a Network Group through its ID or label: @@ -91,35 +69,46 @@ clever ng delete ngId clever ng delete ngLabel ``` -## Manage members of a Network Group +## List Network Groups -To add an application to a Network Group (a `label` is optional): +Once created, you can list your Network Groups: ``` -clever ng members add ngId appId -clever ng members add ngId appId --label 'member label' +clever ng + +┌─────────┬───────-┬─────────-─┬───────────────┬─────────┬───────┐ +| (index) │ ID │ Label │ Network CIDR │ Members │ Peers │ +├─────────┼────────┼───────────┼───────────────┼─────────┼───────┤ +│ 0 │ 'ngId' │ 'ngLabel' │ '10.x.y.z/16' │ X │ Y │ +└─────────┴────────┴──────────-┴───────────────┴─────────┴───────┘ ``` -To get information about members (a `json` formatted output is available): +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 list ngId_or_ngLabel -clever ng members get ngId_or_ngLabel memberId +clever ng members link appId ngIdOrLabel +clever ng members unlink addonId ngIdorLabel ``` -To delete a member from a Network Group: +## 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 members remove ngId_or_ngLabel memberId +clever ng get ngIdOrLabel -F json +clever ng get ressourceIdOrName ``` -## Manage peers of a Network Group +## Get Wireguard configuration of a Peer -To get information about peers (a `json` formatted output is available): +To get the Wireguard configuration of a peer (a `json` formatted output is available): ``` -clever ng peers list ngId_or_ngLabel -clever ng peers get ngId_or_ngLabel peerId +clever ng get-config peerIdOrLabel ``` ## Demos & examples @@ -128,4 +117,4 @@ You can find ready to deploy projects using Network Groups in the following repo - XXX -Create your own and let us know! +Create your own and [let us know](https://github.com/CleverCloud/Community/discussions/categories/network-groups)! diff --git a/src/commands/ng.js b/src/commands/ng.js index a67660d7..34188257 100644 --- a/src/commands/ng.js +++ b/src/commands/ng.js @@ -37,7 +37,7 @@ export async function deleteNg (params) { /** Create an external peer in a Network Group * @param {Object} params * @param {string} params.args[0] External peer ID or label - * @param {string} params.args[1] Wireguard® public key + * @param {string} params.args[1] Wireguard public key * @param {string} params.args[2] Network Group ID or label * @param {string} params.options.org Organisation ID or name */ @@ -50,7 +50,7 @@ export async function createExternalPeer (params) { } if (!publicKey) { - throw new Error('A Wireguard® public key is required'); + throw new Error('A Wireguard public key is required'); } await NGRessources.createExternalPeerWithParent(ngIdOrLabel, idOrLabel.ngRessourceLabel, publicKey, org); Logger.println(`${colors.bold.green('✓')} External peer ${colors.green(idOrLabel.ngRessourceLabel)} successfully created in Network Group ${colors.green(ngIdOrLabel.ngLabel || ngIdOrLabel.ngId)}`); diff --git a/src/experimental-features.js b/src/experimental-features.js index a77d1c52..9916f937 100644 --- a/src/experimental-features.js +++ b/src/experimental-features.js @@ -15,23 +15,26 @@ Learn more about Materia KV: https://www.clever-cloud.com/developers/doc/addons/ }, ng: { status: 'beta', - description: 'Manage Network Groups to link applications, add-ons, external peers through a Wireguard® network', + description: 'Manage Network Groups to link 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, add-on, external): - clever ng create myNG --members-ids appId1,appId2 -- Add an application to an existing Network Group: - clever ng add-app myNG myApp - List Network Groups: - clever ng list -- List Network Groups members: - clever ng members list myNG -- List Network Groups peers (instances of a member): - clever ng peers list myNG + clever ng - Delete a Network Group: clever ng delete myNG - -Learn more about Network Groups: https://github.com/CleverCloud/clever-tools/tree/master/docs/ng.md`, +- Create a Network Group with members (application, add-on, external): + clever ng create myNG --members-ids appId1,appId2 +- (Un)Link an application to an existing Network Group: + clever ng link appId myNG + clever ng unlink appId 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 appId + clever ng get peerId + clever ng get memberLabel +Learn more about Network Groups: https://github.com/CleverCloud/clever-tools/blob/davlgd-new-ng/docs/ng.md`, }, };