UNPKG

clever-tools

Version:

Command Line Interface for Clever Cloud.

320 lines (273 loc) 11 kB
import { create as createAddon, get as getAddon, getAll as getAllAddons, getAllEnvVars, remove as removeAddon, update as updateAddon, } from '@clevercloud/client/esm/api/v2/addon.js'; import { getAllLinkedAddons, linkAddon, unlinkAddon } from '@clevercloud/client/esm/api/v2/application.js'; import { getAllAddonProviders } from '@clevercloud/client/esm/api/v2/product.js'; import { getSummary } from '@clevercloud/client/esm/api/v2/user.js'; import { getAddonProvider } from '@clevercloud/client/esm/api/v4/addon-providers.js'; import cliparse from 'cliparse'; import { confirm } from '../lib/prompts.js'; import { Logger } from '../logger.js'; import { resolveOwnerId } from './ids-resolver.js'; import { sendToApi } from './send-to-api.js'; export function listProviders() { return getAllAddonProviders({}).then(sendToApi); } export async function getProvider(providerName) { const providers = await listProviders(); const provider = providers.find((p) => p.id === providerName); if (provider == null) { throw new Error(`Invalid provider name. Available providers: ${providers.map((p) => p.id).join(', ')}`); } return provider; } export function getProviderInfos(providerName) { return getAddonProvider({ providerId: providerName }) .then(sendToApi) .catch(() => { // An error can occur because the add-on api doesn't implement this endpoint yet // This is fine, just ignore it Logger.debug(`${providerName} doesn't yet implement the provider info endpoint`); return Promise.resolve(null); }); } export async function list(ownerId, appId, showAll) { const allAddons = await getAllAddons({ id: ownerId }).then(sendToApi); if (appId == null) { // Not linked to a specific app, show everything return allAddons; } const myAddons = await getAllLinkedAddons({ id: ownerId, appId }).then(sendToApi); if (!showAll) { return myAddons.map((addon) => ({ ...addon, isLinked: true })); } const myAddonIds = myAddons.map((addon) => addon.id); return allAddons.map((addon) => { const isLinked = myAddonIds.includes(addon.id); return { ...addon, isLinked }; }); } function validateAddonVersionAndOptions(region, version, addonOptions, providerInfos, planType) { if (providerInfos != null) { if (version != null) { const type = planType.value.toLowerCase(); if (type === 'shared') { const cluster = providerInfos.clusters.find(({ zone }) => zone === region); if (cluster == null) { throw new Error(`Can't find cluster for region ${region}`); } else if (cluster.version !== version) { throw new Error( `Invalid version ${version}, selected shared cluster only supports version ${cluster.version}`, ); } } else if (type === 'dedicated') { const availableVersions = Object.keys(providerInfos.dedicated); const hasVersion = availableVersions.find((availableVersion) => availableVersion === version); if (hasVersion == null) { throw new Error( `Invalid version ${addonOptions.version}, available versions are: ${availableVersions.join(', ')}`, ); } } } const chosenVersion = version != null ? version : providerInfos.defaultDedicatedVersion; // Check the selected options to see if the chosen plan / region offers them // If not, abort the creation if (Object.keys(addonOptions).length > 0) { const type = planType.value.toLowerCase(); let availableOptions = []; if (type === 'shared') { const cluster = providerInfos.clusters.find(({ zone }) => zone === region); if (cluster == null) { throw new Error(`Can't find cluster for region ${region}`); } availableOptions = cluster.features; } else if (type === 'dedicated') { availableOptions = providerInfos.dedicated[chosenVersion].features; } for (const selectedOption in addonOptions) { const isAvailable = availableOptions.find(({ name }) => name === selectedOption); if (isAvailable == null) { const optionNames = availableOptions.map(({ name }) => name).join(','); let availableOptionsError = null; if (optionNames.length > 0) { availableOptionsError = `Available options are: ${optionNames}.`; } else { availableOptionsError = 'No options are available for this plan.'; } throw new Error(`Option "${selectedOption}" is not available on this plan. ${availableOptionsError}`); } } } return { version: chosenVersion, ...addonOptions, }; } else { if (version != null) { throw new Error("You provided a version for an add-on that doesn't support choosing the version."); } return {}; } } export async function create({ ownerId, name, providerName, planName, region, version, addonOptions }) { // TODO: We should be able to use it without {} const provider = await getProvider(providerName); if (!provider.regions.includes(region)) { throw new Error(`Invalid region name. Available regions: ${provider.regions.join(', ')}`); } if (provider.plans.length === 0) { throw new Error(`No plans available for provider ${providerName}`); } const plan = getPlan(planName, provider.plans); const providerInfos = await getProviderInfos(provider.id); const planType = plan.features.find(({ name }) => name.toLowerCase() === 'type'); // If we have a providerInfos but we don't have a planType, we won't be able to go further // The process should stop here to make sure users don't create something they don't intend to // This missing feature should have been added during the add-on's development phase // The console has a similar check so I believe we shouldn't hit this if (providerInfos != null && planType == null) { throw new Error( 'Internal error. The selected plan misses the TYPE feature. Please contact our support with the command line you used', ); } const createOptions = validateAddonVersionAndOptions(region, version, addonOptions, providerInfos, planType); const addonToCreate = { name, plan: plan.id, providerId: provider.id, region, options: createOptions, }; const createdAddon = await createAddon({ id: ownerId }, addonToCreate).then(sendToApi); createdAddon.env = await getAllEnvVars({ id: ownerId, addonId: createdAddon.id }).then(sendToApi); return createdAddon; } async function getByName(ownerId, addonNameOrRealId) { const addons = await getAllAddons({ id: ownerId }).then(sendToApi); const filteredAddons = addons.filter(({ name, realId }) => { return name === addonNameOrRealId || realId === addonNameOrRealId; }); if (filteredAddons.length === 1) { return filteredAddons[0]; } if (filteredAddons.length === 0) { throw new Error('Addon not found'); } throw new Error('Ambiguous addon name'); } async function getId(ownerId, addon) { if (addon.addon_id) { return addon.addon_id; } const addonDetails = await getByName(ownerId, addon.addon_name); return addonDetails.id; } export async function link(ownerId, appId, addon) { const addonId = await getId(ownerId, addon); return linkAddon({ id: ownerId, appId }, JSON.stringify(addonId)).then(sendToApi); } export async function unlink(ownerId, appId, addon) { const addonId = await getId(ownerId, addon); return unlinkAddon({ id: ownerId, appId, addonId }).then(sendToApi); } export async function deleteAddon(ownerId, addonIdOrName, skipConfirmation) { const addonId = await getId(ownerId, addonIdOrName); if (!skipConfirmation) { await confirm("Deleting the add-on can't be undone, are you sure?", 'No confirmation, aborting add-on deletion'); } return removeAddon({ id: ownerId, addonId }).then(sendToApi); } export async function rename(ownerId, addon, name) { const addonId = await getId(ownerId, addon); return updateAddon({ id: ownerId, addonId }, { name }).then(sendToApi); } export function completeRegion() { return cliparse.autocomplete.words(['par', 'mtl']); } // TODO: We need to fix this export function completePlan() { return cliparse.autocomplete.words(['dev', 's', 'm', 'l', 'xl', 'xxl']); } export async function findById(addonId) { const { user, organisations } = await getSummary({}).then(sendToApi); for (const orga of [user, ...organisations]) { for (const simpleAddon of orga.addons) { if (simpleAddon.id === addonId) { const addon = await getAddon({ id: orga.id, addonId }).then(sendToApi); return { ...addon, orgaId: orga.id, }; } } } throw new Error(`Could not find add-on with ID: ${addonId}`); } export async function findByName(addonName) { const { user, organisations } = await getSummary({}).then(sendToApi); for (const orga of [user, ...organisations]) { for (const simpleAddon of orga.addons) { if (simpleAddon.name === addonName) { const addon = await getAddon({ id: orga.id, addonId: simpleAddon.id }).then(sendToApi); return { ...addon, orgaId: orga.id, }; } } } throw new Error(`Could not find add-on with name ${addonName}`); } export async function findOwnerId(org, addonId) { if (org != null && org.orga_id != null) { return org.orga_id; } const ownerId = await resolveOwnerId(addonId); if (ownerId != null) { return ownerId; } throw new Error(`Add-on ${addonId} does not exist`); } export function parseAddonOptions(options) { if (options == null) { return {}; } const pairs = options.split(/,(?=\w+(?:-\w+)*=)/) || []; return pairs.reduce((options, pair) => { const [key, ...valueParts] = pair.split('='); const value = valueParts.join('='); if (value == null) { throw new Error("Options are malformed. Usage is '--option name=enabled|disabled|true|false|plugin1,plugin2'"); } let formattedValue = value; if (value === 'true' || value === 'enabled') { formattedValue = 'true'; } else if (value === 'false' || value === 'disabled') { formattedValue = 'false'; } else if (typeof key === 'string') { formattedValue = value; } else { throw new Error(`Can't parse option value: ${value}. Accepted values are: enabled, disabled, true, false`); } options[key] = formattedValue; return options; }, {}); } function getPlan(planName, plans) { // if no plan specified, pick the cheapest one if (planName == null || planName === '') { return plans.sort((p1, p2) => p1.price - p2.price)[0]; } const plan = plans.find((p) => p.slug.toLowerCase() === planName.toLowerCase()); if (plan == null) { const availablePlans = plans.map((p) => p.slug); throw new Error(`Invalid plan name. Available plans: ${availablePlans.join(', ')}`); } return plan; }