Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(features): add experimental features management #785

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions bin/clever.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import * as Parsers from '../src/parsers.js';
import { handleCommandPromise } from '../src/command-promise-handler.js';
import * as Application from '../src/models/application.js';
import { AVAILABLE_ZONES } from '../src/models/application.js';
import { getExitOnOption, getOutputFormatOption, getSameCommitPolicyOption } from '../src/command-options.js';
import { getOutputFormatOption, getSameCommitPolicyOption, getExitOnOption } from '../src/command-options.js';

import * as Addon from '../src/models/addon.js';
import * as ApplicationConfiguration from '../src/models/application_configuration.js';
Expand All @@ -34,6 +34,7 @@ import * as diag from '../src/commands/diag.js';
import * as domain from '../src/commands/domain.js';
import * as drain from '../src/commands/drain.js';
import * as env from '../src/commands/env.js';
import * as features from '../src/commands/features.js';
import * as link from '../src/commands/link.js';
import * as login from '../src/commands/login.js';
import * as logout from '../src/commands/logout.js';
Expand Down Expand Up @@ -74,7 +75,7 @@ cliparse.command = function (name, options, commandFunction) {
});
};

function run () {
async function run () {

// ARGUMENTS
const args = {
Expand Down Expand Up @@ -102,6 +103,11 @@ function run () {
}),
drainUrl: cliparse.argument('drain-url', { description: 'Drain URL' }),
fqdn: cliparse.argument('fqdn', { description: 'Domain name of the application' }),
features: cliparse.argument('features', {
description: 'Comma-separated list of experimental features to manage',
parser: Parsers.commaSeparated,
}),
featureId: cliparse.argument('feature', { description: 'Experimental feature to manage' }),
notificationName: cliparse.argument('name', { description: 'Notification name' }),
notificationId: cliparse.argument('notification-id', { description: 'Notification ID' }),
webhookUrl: cliparse.argument('url', { description: 'Webhook URL' }),
Expand Down Expand Up @@ -665,6 +671,28 @@ function run () {
commands: [envSetCommand, envRemoveCommand, envImportCommand, envImportVarsFromLocalEnvCommand],
}, env.list);

// EXPERIMENTAL FEATURES COMMAND
const listFeaturesCommand = cliparse.command('list', {
description: 'List available experimental features',
options: [opts.humanJsonOutputFormat],
}, features.list);
const helpFeaturesCommand = cliparse.command('help', {
description: 'Display help about an experimental feature',
args: [args.featureId],
}, features.help);
const enableFeatureCommand = cliparse.command('enable', {
description: 'Enable an experimental feature',
args: [args.features],
}, features.enable);
const disableFeatureCommand = cliparse.command('disable', {
description: 'Disable an experimental feature',
args: [args.features],
}, features.disable);
const featuresCommands = cliparse.command('features', {
description: 'Manage Clever Tools experimental features',
commands: [enableFeatureCommand, disableFeatureCommand, listFeaturesCommand, helpFeaturesCommand],
}, features.list);

// LINK COMMAND
const appLinkCommand = cliparse.command('link', {
description: 'Link this repo to an existing application',
Expand Down Expand Up @@ -880,7 +908,7 @@ function run () {
// Patch help command description
cliparseCommands.helpCommand.description = 'Display help about the Clever Cloud CLI';

const commands = _sortBy([
let commands = [
accesslogsCommand,
activityCommand,
addonCommands,
Expand All @@ -899,6 +927,7 @@ function run () {
drainCommands,
emailNotificationsCommand,
envCommands,
featuresCommands,
cliparseCommands.helpCommand,
loginCommand,
logoutCommand,
Expand All @@ -917,7 +946,10 @@ function run () {
tcpRedirsCommands,
versionCommand,
webhooksCommand,
], 'name');
];

// We sort the commands by name
commands = _sortBy(commands, 'name');

// CLI PARSER
const cliParser = cliparse.cli({
Expand Down
98 changes: 98 additions & 0 deletions src/commands/features.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { getFeatures, setFeature } from '../models/configuration.js';
import { EXPERIMENTAL_FEATURES } from '../experimental-features.js';
import { formatTable } from '../format-table.js';
import { Logger } from '../logger.js';

function getPropertyMaxWidth (array, propertyName) {
return Math.max(...array.map((o) => o[propertyName].length));
}

export async function list (params) {
const { format } = params.options;

const features_conf = await getFeatures();
// Add status from configuration file and remove instructions
const features = Object.entries(EXPERIMENTAL_FEATURES).map(([id, feature]) => {
const enabled = features_conf[id] === true;
return { ...feature, id, enabled, instructions: undefined };
});

// For each feature, print the object with the id, status, description and enabled
switch (format) {
case 'json': {
Logger.printJson(features);
break;
}
case 'human':
default: {
const headers = ['ID', 'STATUS', 'DESCRIPTION', 'ENABLED'];
const columnWidths = [
getPropertyMaxWidth(features, 'id'),
getPropertyMaxWidth(features, 'status'),
getPropertyMaxWidth(features, 'description'),
getPropertyMaxWidth(features, 'enabled'),
];

// We calculate the maximum width of each column to format the table
const formatTableWithColumnWidth = formatTable(columnWidths);

Logger.println(formatTableWithColumnWidth([
headers,
...features.map((feature) => [
feature.id,
feature.status,
feature.description,
feature.enabled,
]),
]));
}
}
}

export async function help (params) {
const { feature } = params.namedArgs;
const availableFeatures = Object.keys(EXPERIMENTAL_FEATURES);

if (!availableFeatures.includes(feature)) {
Logger.printErrorLine(`Feature '${feature}' is not available`);
}
else {
Logger.println(EXPERIMENTAL_FEATURES[feature].instructions);
}
}

export async function enable (params) {
const { features } = params.namedArgs;
const availableFeatures = Object.keys(EXPERIMENTAL_FEATURES);
const canEnableFeatures = features.filter((feature) => availableFeatures.includes(feature));

for (const featureName of features) {
if (!availableFeatures.includes(featureName)) {
Logger.printErrorLine(`Feature '${featureName}' is not available`);
continue;
}
await setFeature(featureName, true);
Logger.println(`Experimental feature '${featureName}' enabled`);

if (canEnableFeatures.length === 1) Logger.println(EXPERIMENTAL_FEATURES[featureName].instructions);
}

if (canEnableFeatures.length > 1) {
Logger.println();
Logger.println("To learn more about these experimental features, use 'clever features help FEATURE_NAME'");
}
}

export async function disable (params) {
const { features } = params.namedArgs;
const availableFeatures = Object.keys(EXPERIMENTAL_FEATURES);

for (const featureName of features) {
if (!availableFeatures.includes(featureName)) {
Logger.printErrorLine(`Feature '${featureName}' is not available`);
continue;
}
await setFeature(featureName, false);
Logger.println(`Experimental feature '${featureName}' disabled`);
}
}
49 changes: 49 additions & 0 deletions src/experimental-features.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
export const EXPERIMENTAL_FEATURES = {
kv: {
status: 'alpha',
description: 'Send commands to Materia KV directly from Clever Tools, without other dependencies',
instructions: `
To use Materia KV from Clever Tools, you need the 'KV_TOKEN' environment variable set.

Then you can directly send any supported command to Materia KV:

clever kv ping
clever kv set myKey myValue
clever kv set myTempKey myTempValue ex 120
clever kv get myKey
clever kv ttl myTempKey

You can also use the 'clever kv getJson' command to query a value from key containing a JSON object:

clever kv set simpleJson '{"key": "value"}'
clever kv getJson simpleJson key
clever kv set jsonKey '[{"key": "value"}, {"bigKey": {"subKey1": "subValue1","subKey2": "subValue2"}}]'
clever kv getjson jsonKey bigKey.subKey2
clever kv getjson jsonKey ''

Learn more about Materia KV: https://developers.clever-cloud.com/doc/addons/materia-kv/
`,
},
ng: {
status: 'beta',
description: 'Manage Network Groups to link applications, add-ons, external peers in 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
`,
},
};
44 changes: 44 additions & 0 deletions src/models/configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const env = commonEnv(Logger);
const CONFIG_FILES = {
MAIN: 'clever-tools.json',
IDS_CACHE: 'ids-cache.json',
EXPERIMENTAL_FEATURES_FILE: 'clever-tools-experimental-features.json',
};

function getConfigPath (configFile) {
Expand Down Expand Up @@ -85,6 +86,48 @@ export async function writeIdsCache (ids) {
}
}

export async function loadFeaturesConf () {
Logger.debug('Load features configuration from ' + conf.EXPERIMENTAL_FEATURES_FILE);

try {
const rawFile = await fs.readFile(conf.EXPERIMENTAL_FEATURES_FILE);
return JSON.parse(rawFile);
}
catch (error) {
if (error.code !== 'ENOENT') {
Logger.info(`Cannot load experimental features configuration from ${conf.EXPERIMENTAL_FEATURES_FILE}`);
}

return {};
}
}

export async function getFeatures () {
Logger.debug('Get features configuration from ' + conf.EXPERIMENTAL_FEATURES_FILE);
try {
const rawFile = await fs.readFile(conf.EXPERIMENTAL_FEATURES_FILE);
return JSON.parse(rawFile);
}
catch (error) {
if (error.code !== 'ENOENT') {
throw new Error(`Cannot get experimental features configuration from ${conf.EXPERIMENTAL_FEATURES_FILE}`);
}
return {};
}
}

export async function setFeature (feature, value) {
const currentFeatures = await getFeatures();
const newFeatures = { ...currentFeatures, ...{ [feature]: value } };

try {
await fs.writeFile(conf.EXPERIMENTAL_FEATURES_FILE, JSON.stringify(newFeatures, null, 2));
}
catch (error) {
throw new Error(`Cannot write experimental features configuration to ${conf.EXPERIMENTAL_FEATURES_FILE}`);
}
}

export const conf = env.getOrElseAll({
API_HOST: 'https://api.clever-cloud.com',
// API_HOST: 'https://ccapi-preprod.cleverapps.io',
Expand All @@ -98,6 +141,7 @@ export const conf = env.getOrElseAll({
SSH_GATEWAY: '[email protected]',

CONFIGURATION_FILE: getConfigPath(CONFIG_FILES.MAIN),
EXPERIMENTAL_FEATURES_FILE: getConfigPath(CONFIG_FILES.EXPERIMENTAL_FEATURES_FILE),
CONSOLE_TOKEN_URL: 'https://console.clever-cloud.com/cli-oauth',
// CONSOLE_TOKEN_URL: 'https://next-console.cleverapps.io/cli-oauth',

Expand Down
Loading