Skip to content

Commit

Permalink
Merge pull request #785 from CleverCloud/davlgd-feature-flags
Browse files Browse the repository at this point in the history
feat(features): add experimental features management
  • Loading branch information
hsablonniere authored Dec 2, 2024
2 parents 4a2b19d + 5c370b3 commit d4c0828
Show file tree
Hide file tree
Showing 4 changed files with 154 additions and 3 deletions.
35 changes: 32 additions & 3 deletions bin/clever.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 @@ -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 @@ -666,6 +672,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 infoFeaturesCommand = cliparse.command('info', {
description: 'Display info about an experimental feature',
args: [args.featureId],
}, features.info);
const enableFeatureCommand = cliparse.command('enable', {
description: 'Enable experimental features',
args: [args.features],
}, features.enable);
const disableFeatureCommand = cliparse.command('disable', {
description: 'Disable experimental features',
args: [args.features],
}, features.disable);
const featuresCommands = cliparse.command('features', {
description: 'Manage Clever Tools experimental features',
commands: [enableFeatureCommand, disableFeatureCommand, listFeaturesCommand, infoFeaturesCommand],
}, features.list);

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

const commands = _sortBy([
const commands = [
accesslogsCommand,
activityCommand,
addonCommands,
Expand All @@ -900,6 +928,7 @@ function run () {
drainCommands,
emailNotificationsCommand,
envCommands,
featuresCommands,
cliparseCommands.helpCommand,
loginCommand,
logoutCommand,
Expand All @@ -918,7 +947,7 @@ function run () {
tcpRedirsCommands,
versionCommand,
webhooksCommand,
], 'name');
];

// CLI PARSER
const cliParser = cliparse.cli({
Expand All @@ -927,7 +956,7 @@ function run () {
version: getPackageJson().version,
options: [opts.color, opts.updateNotifier, opts.verbose],
helpCommand: false,
commands,
commands: _sortBy(commands, 'name'),
});

// Make sure argv[0] is always "node"
Expand Down
93 changes: 93 additions & 0 deletions src/commands/features.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { getFeatures, setFeature } from '../models/configuration.js';
import { EXPERIMENTAL_FEATURES } from '../experimental-features.js';
import { formatTable as initFormatTable } from '../format-table.js';
import { Logger } from '../logger.js';

const formatTable = initFormatTable();

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

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

// 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'];

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

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

if (!availableFeatures.includes(feature)) {
throw new Error(`Unavailable feature: ${feature}`);
}

Logger.println(EXPERIMENTAL_FEATURES[feature].instructions);
}

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

const unknownFeatures = features.filter((feature) => !availableFeatures.includes(feature));
if (unknownFeatures.length > 0) {
throw new Error(`Unavailable feature(s): ${unknownFeatures.join(', ')}`);
}

for (const featureName of features) {
await setFeature(featureName, true);
Logger.println(`Experimental feature '${featureName}' enabled`);
}

if (features.length === 1) {
Logger.println(EXPERIMENTAL_FEATURES[features[0]].instructions);
}
else {
Logger.println();
Logger.println("To learn more about these experimental features, use 'clever features info FEATURE_NAME'");
}
}

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

const unknownFeatures = features.filter((feature) => !availableFeatures.includes(feature));
if (unknownFeatures.length > 0) {
throw new Error(`Unavailable feature(s): ${unknownFeatures.join(', ')}`);
}

for (const featureName of features) {
await setFeature(featureName, false);
Logger.println(`Experimental feature '${featureName}' disabled`);
}
}
1 change: 1 addition & 0 deletions src/experimental-features.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const EXPERIMENTAL_FEATURES = {};
28 changes: 28 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,32 @@ export async function writeIdsCache (ids) {
}
}

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 +125,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

0 comments on commit d4c0828

Please sign in to comment.