From e0695f94f0a54e6d31c627ea2b0b139895a21bc1 Mon Sep 17 00:00:00 2001 From: Jason Barry Date: Thu, 30 Jun 2022 12:28:03 -0700 Subject: [PATCH] feat: support env write operations for sites opted in to beta env var experience (#4751) * feat: support envelope in env:set * chore: refactor * feat: support envelope in env:unset * feat: support envelope in env:import * chore: add param defaults to helper methods * fix: safer assignment in set command if env is nil * fix: imported env should always take precedence in case of name collision * feat: support envelope in env:migrate * test: pass integration test locally * test: adding unit tests * chore: update contributors field * chore: update contributors field * test: adding integration tests * chore: adding headerdoc * chore: update contributors field * chore: update contributors field * chore: update parameter name in docs markdown file * chore: update contributors field * chore: update contributors field * fix: addressing PR feedback * chore: update contributors field * chore: update contributors field Co-authored-by: Jason Barry Co-authored-by: jasonbarry Co-authored-by: token-generator-app[bot] Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- docs/commands/env.md | 4 +- src/commands/env/env-import.js | 86 +++- src/commands/env/env-migrate.js | 147 +++++- src/commands/env/env-set.js | 71 ++- src/commands/env/env-unset.js | 68 ++- src/utils/env/index.js | 43 ++ src/utils/index.js | 2 + .../integration/650.command.envelope.test.js | 420 ++++++++++++++++++ .../snapshots/650.command.envelope.test.js.md | 23 + .../650.command.envelope.test.js.snap | Bin 0 -> 239 bytes tests/unit/utils/env/index.test.js | 107 +++++ 11 files changed, 910 insertions(+), 61 deletions(-) create mode 100644 src/utils/env/index.js create mode 100644 tests/integration/650.command.envelope.test.js create mode 100644 tests/integration/snapshots/650.command.envelope.test.js.md create mode 100644 tests/integration/snapshots/650.command.envelope.test.js.snap create mode 100644 tests/unit/utils/env/index.test.js diff --git a/docs/commands/env.md b/docs/commands/env.md index f04f43fc119..1bb47f459ba 100644 --- a/docs/commands/env.md +++ b/docs/commands/env.md @@ -140,7 +140,7 @@ netlify env:set **Arguments** -- name - Environment variable name +- key - Environment variable key - value - Value to set to **Flags** @@ -162,7 +162,7 @@ netlify env:unset **Arguments** -- name - Environment variable name +- key - Environment variable key **Flags** diff --git a/src/commands/env/env-import.js b/src/commands/env/env-import.js index f8f81c3eb6f..a19ffed06dc 100644 --- a/src/commands/env/env-import.js +++ b/src/commands/env/env-import.js @@ -5,7 +5,7 @@ const AsciiTable = require('ascii-table') const dotenv = require('dotenv') const isEmpty = require('lodash/isEmpty') -const { exit, log, logJson } = require('../../utils') +const { exit, log, logJson, translateFromEnvelopeToMongo, translateFromMongoToEnvelope } = require('../../utils') /** * The env:import command @@ -23,13 +23,6 @@ const envImport = async (fileName, options, command) => { return false } - const siteData = await api.getSite({ siteId }) - - // Get current environment variables set in the UI - const { - build_settings: { env = {} }, - } = siteData - let importedEnv = {} try { const envFileContents = await readFile(fileName, 'utf-8') @@ -44,21 +37,14 @@ const envImport = async (fileName, options, command) => { return false } - // Apply environment variable updates - const siteResult = await api.updateSite({ - siteId, - body: { - build_settings: { - // Only set imported variables if --replaceExisting or otherwise merge - // imported ones with the current environment variables. - env: options.replaceExisting ? importedEnv : { ...env, ...importedEnv }, - }, - }, - }) + const siteData = await api.getSite({ siteId }) + + const importIntoService = siteData.use_envelope ? importIntoEnvelope : importIntoMongo + const finalEnv = await importIntoService({ api, importedEnv, options, siteData }) // Return new environment variables of site if using json flag if (options.json) { - logJson(siteResult.build_settings.env) + logJson(finalEnv) return false } @@ -71,6 +57,66 @@ const envImport = async (fileName, options, command) => { log(table.toString()) } +/** + * Updates the imported env in the site record + * @returns {Promise} + */ +const importIntoMongo = async ({ api, importedEnv, options, siteData }) => { + const { env = {} } = siteData.build_settings + const siteId = siteData.id + + const finalEnv = options.replaceExisting ? importedEnv : { ...env, ...importedEnv } + + // Apply environment variable updates + await api.updateSite({ + siteId, + body: { + build_settings: { + // Only set imported variables if --replaceExisting or otherwise merge + // imported ones with the current environment variables. + env: finalEnv, + }, + }, + }) + + return finalEnv +} + +/** + * Saves the imported env in the Envelope service + * @returns {Promise} + */ +const importIntoEnvelope = async ({ api, importedEnv, options, siteData }) => { + // fetch env vars + const accountId = siteData.account_slug + const siteId = siteData.id + const dotEnvKeys = Object.keys(importedEnv) + const envelopeVariables = await api.getEnvVars({ accountId, siteId }) + const envelopeKeys = envelopeVariables.map(({ key }) => key) + + // if user intends to replace all existing env vars + // either replace; delete all existing env vars on the site + // or, merge; delete only the existing env vars that would collide with new .env entries + const keysToDelete = options.replaceExisting ? envelopeKeys : envelopeKeys.filter((key) => dotEnvKeys.includes(key)) + + // delete marked env vars in parallel + await Promise.all(keysToDelete.map((key) => api.deleteEnvVar({ accountId, siteId, key }))) + + // hit create endpoint + const body = translateFromMongoToEnvelope(importedEnv) + try { + await api.createEnvVars({ accountId, siteId, body }) + } catch (error) { + throw error.json ? error.json.msg : error + } + + // return final env to aid in --json output (for testing) + return { + ...translateFromEnvelopeToMongo(envelopeVariables.filter(({ key }) => !keysToDelete.includes(key))), + ...importedEnv, + } +} + /** * Creates the `netlify env:import` command * @param {import('../base-command').BaseCommand} program diff --git a/src/commands/env/env-migrate.js b/src/commands/env/env-migrate.js index 4b2e966d853..de4919002d3 100644 --- a/src/commands/env/env-migrate.js +++ b/src/commands/env/env-migrate.js @@ -2,7 +2,13 @@ const { isEmpty } = require('lodash') -const { chalk, error: logError, log } = require('../../utils') +const { + chalk, + error: logError, + log, + translateFromEnvelopeToMongo, + translateFromMongoToEnvelope, +} = require('../../utils') const safeGetSite = async (api, siteId) => { try { @@ -51,6 +57,37 @@ const envMigrate = async (options, command) => { return false } + // determine if siteFrom and/or siteTo is on Envelope + let method + if (!siteFrom.use_envelope && !siteTo.use_envelope) { + method = mongoToMongo + } else if (!siteFrom.use_envelope && siteTo.use_envelope) { + method = mongoToEnvelope + } else if (siteFrom.use_envelope && !siteTo.use_envelope) { + method = envelopeToMongo + } else { + method = envelopeToEnvelope + } + const success = await method({ api, siteFrom, siteTo }) + + if (!success) { + return false + } + + log( + `Successfully migrated environment variables from ${chalk.greenBright(siteFrom.name)} to ${chalk.greenBright( + siteTo.name, + )}`, + ) + + return true +} + +/** + * Copies the env from a site not configured with Envelope to a different site not configured with Envelope + * @returns {Promise} + */ +const mongoToMongo = async ({ api, siteFrom, siteTo }) => { const [ { build_settings: { env: envFrom = {} }, @@ -73,7 +110,7 @@ const envMigrate = async (options, command) => { // Apply environment variable updates await api.updateSite({ - siteId: siteId.to, + siteId: siteTo.id, body: { build_settings: { env: mergedEnv, @@ -81,11 +118,107 @@ const envMigrate = async (options, command) => { }, }) - log( - `Successfully migrated environment variables from ${chalk.greenBright(siteFrom.name)} to ${chalk.greenBright( - siteTo.name, - )}`, - ) + return true +} + +/** + * Copies the env from a site not configured with Envelope to a site configured with Envelope + * @returns {Promise} + */ +const mongoToEnvelope = async ({ api, siteFrom, siteTo }) => { + const envFrom = siteFrom.build_settings.env || {} + const keysFrom = Object.keys(envFrom) + + if (isEmpty(envFrom)) { + log(`${chalk.greenBright(siteFrom.name)} has no environment variables, nothing to migrate`) + return false + } + + const accountId = siteTo.account_slug + const siteId = siteTo.id + + const envelopeTo = await api.getEnvVars({ accountId, siteId }) + + const envVarsToDelete = envelopeTo.filter(({ key }) => keysFrom.includes(key)) + // delete marked env vars in parallel + await Promise.all(envVarsToDelete.map(({ key }) => api.deleteEnvVar({ accountId, siteId, key }))) + + // hit create endpoint + const body = translateFromMongoToEnvelope(envFrom) + try { + await api.createEnvVars({ accountId, siteId, body }) + } catch (error) { + throw error.json ? error.json.msg : error + } + + return true +} + +/** + * Copies the env from a site configured with Envelope to a site not configured with Envelope + * @returns {Promise} + */ +const envelopeToMongo = async ({ api, siteFrom, siteTo }) => { + const envelopeVariables = await api.getEnvVars({ accountId: siteFrom.account_slug, siteId: siteFrom.id }) + const envFrom = translateFromEnvelopeToMongo(envelopeVariables) + + if (isEmpty(envFrom)) { + log(`${chalk.greenBright(siteFrom.name)} has no environment variables, nothing to migrate`) + return false + } + + const envTo = siteTo.build_settings.env || {} + + // Merge from site A to site B + const mergedEnv = { + ...envTo, + ...envFrom, + } + + // Apply environment variable updates + await api.updateSite({ + siteId: siteTo.id, + body: { + build_settings: { + env: mergedEnv, + }, + }, + }) + + return true +} + +/** + * Copies the env from a site configured with Envelope to a different site configured with Envelope + * @returns {Promise} + */ +const envelopeToEnvelope = async ({ api, siteFrom, siteTo }) => { + const [envelopeFrom, envelopeTo] = await Promise.all([ + api.getEnvVars({ accountId: siteFrom.account_slug, siteId: siteFrom.id }), + api.getEnvVars({ accountId: siteTo.account_slug, siteId: siteTo.id }), + ]) + + const keysFrom = envelopeFrom.map(({ key }) => key) + + if (isEmpty(keysFrom)) { + log(`${chalk.greenBright(siteFrom.name)} has no environment variables, nothing to migrate`) + return false + } + + const accountId = siteTo.account_slug + const siteId = siteTo.id + const envVarsToDelete = envelopeTo.filter(({ key }) => keysFrom.includes(key)) + // delete marked env vars in parallel + await Promise.all(envVarsToDelete.map(({ key }) => api.deleteEnvVar({ accountId, siteId, key }))) + + // hit create endpoint + try { + await api.createEnvVars({ accountId, siteId, body: envelopeFrom }) + } catch (error) { + throw error.json ? error.json.msg : error + } + + return true } /** diff --git a/src/commands/env/env-set.js b/src/commands/env/env-set.js index 3bf5829f506..71db790c6be 100644 --- a/src/commands/env/env-set.js +++ b/src/commands/env/env-set.js @@ -1,15 +1,15 @@ // @ts-check -const { log, logJson } = require('../../utils') +const { log, logJson, translateFromEnvelopeToMongo } = require('../../utils') /** * The env:set command - * @param {string} name Environment variable name + * @param {string} key Environment variable key * @param {string} value Value to set to * @param {import('commander').OptionValues} options * @param {import('../base-command').BaseCommand} command * @returns {Promise} */ -const envSet = async (name, value, options, command) => { +const envSet = async (key, value, options, command) => { const { api, site } = command.netlify const siteId = site.id @@ -21,32 +21,67 @@ const envSet = async (name, value, options, command) => { const siteData = await api.getSite({ siteId }) // Get current environment variables set in the UI - const { - build_settings: { env = {} }, - } = siteData + const setInService = siteData.use_envelope ? setInEnvelope : setInMongo + const finalEnv = await setInService({ api, siteData, key, value }) + // Return new environment variables of site if using json flag + if (options.json) { + logJson(finalEnv) + return false + } + + log(`Set environment variable ${key}=${value} for site ${siteData.name}`) +} + +/** + * Updates the env for a site record with a new key/value pair + * @returns {Promise} + */ +const setInMongo = async ({ api, key, siteData, value }) => { + const { env = {} } = siteData.build_settings const newEnv = { ...env, - [name]: value, + [key]: value, } - // Apply environment variable updates - const siteResult = await api.updateSite({ - siteId, + await api.updateSite({ + siteId: siteData.id, body: { build_settings: { env: newEnv, }, }, }) + return newEnv +} - // Return new environment variables of site if using json flag - if (options.json) { - logJson(siteResult.build_settings.env) - return false +/** + * Updates the env for a site configured with Envelope with a new key/value pair + * @returns {Promise} + */ +const setInEnvelope = async ({ api, key, siteData, value }) => { + const accountId = siteData.account_slug + const siteId = siteData.id + // fetch envelope env vars + const envelopeVariables = await api.getEnvVars({ accountId, siteId }) + const scopes = ['builds', 'functions', 'runtime', 'post_processing'] + const values = [{ context: 'all', value }] + // check if we need to create or update + const exists = envelopeVariables.some((envVar) => envVar.key === key) + const method = exists ? api.updateEnvVar : api.createEnvVars + const body = exists ? { key, scopes, values } : [{ key, scopes, values }] + + try { + await method({ accountId, siteId, key, body }) + } catch (error) { + throw error.json ? error.json.msg : error } - log(`Set environment variable ${name}=${value} for site ${siteData.name}`) + const env = translateFromEnvelopeToMongo(envelopeVariables) + return { + ...env, + [key]: value, + } } /** @@ -57,11 +92,11 @@ const envSet = async (name, value, options, command) => { const createEnvSetCommand = (program) => program .command('env:set') - .argument('', 'Environment variable name') + .argument('', 'Environment variable key') .argument('[value]', 'Value to set to', '') .description('Set value of environment variable') - .action(async (name, value, options, command) => { - await envSet(name, value, options, command) + .action(async (key, value, options, command) => { + await envSet(key, value, options, command) }) module.exports = { createEnvSetCommand } diff --git a/src/commands/env/env-unset.js b/src/commands/env/env-unset.js index dad95a9c8eb..d3c08d606b2 100644 --- a/src/commands/env/env-unset.js +++ b/src/commands/env/env-unset.js @@ -1,14 +1,14 @@ // @ts-check -const { log, logJson } = require('../../utils') +const { log, logJson, translateFromEnvelopeToMongo } = require('../../utils') /** * The env:unset command - * @param {string} name Environment variable name + * @param {string} key Environment variable key * @param {import('commander').OptionValues} options * @param {import('../base-command').BaseCommand} command * @returns {Promise} */ -const envUnset = async (name, options, command) => { +const envUnset = async (key, options, command) => { const { api, site } = command.netlify const siteId = site.id @@ -19,6 +19,23 @@ const envUnset = async (name, options, command) => { const siteData = await api.getSite({ siteId }) + const unsetInService = siteData.use_envelope ? unsetInEnvelope : unsetInMongo + const finalEnv = await unsetInService({ api, siteData, key }) + + // Return new environment variables of site if using json flag + if (options.json) { + logJson(finalEnv) + return false + } + + log(`Unset environment variable ${key} for site ${siteData.name}`) +} + +/** + * Deletes a given key from the env of a site record + * @returns {Promise} + */ +const unsetInMongo = async ({ api, key, siteData }) => { // Get current environment variables set in the UI const { build_settings: { env = {} }, @@ -27,11 +44,11 @@ const envUnset = async (name, options, command) => { const newEnv = env // Delete environment variable from current variables - delete newEnv[name] + delete newEnv[key] // Apply environment variable updates - const siteResult = await api.updateSite({ - siteId, + await api.updateSite({ + siteId: siteData.id, body: { build_settings: { env: newEnv, @@ -39,13 +56,36 @@ const envUnset = async (name, options, command) => { }, }) - // Return new environment variables of site if using json flag - if (options.json) { - logJson(siteResult.build_settings.env) - return false + return newEnv +} + +/** + * Deletes a given key from the env of a site configured with Envelope + * @returns {Promise} + */ +const unsetInEnvelope = async ({ api, key, siteData }) => { + const accountId = siteData.account_slug + const siteId = siteData.id + // fetch envelope env vars + const envelopeVariables = await api.getEnvVars({ accountId, siteId }) + + // check if the given key exists + const env = translateFromEnvelopeToMongo(envelopeVariables) + if (!env[key]) { + // if not, no need to call delete; return early + return env } - log(`Unset environment variable ${name} for site ${siteData.name}`) + // delete the given key + try { + await api.deleteEnvVar({ accountId, siteId, key }) + } catch (error) { + throw error.json ? error.json.msg : error + } + + delete env[key] + + return env } /** @@ -57,10 +97,10 @@ const createEnvUnsetCommand = (program) => program .command('env:unset') .aliases(['env:delete', 'env:remove']) - .argument('', 'Environment variable name') + .argument('', 'Environment variable key') .description('Unset an environment variable which removes it from the UI') - .action(async (name, options, command) => { - await envUnset(name, options, command) + .action(async (key, options, command) => { + await envUnset(key, options, command) }) module.exports = { createEnvUnsetCommand } diff --git a/src/utils/env/index.js b/src/utils/env/index.js new file mode 100644 index 00000000000..07ad82bffec --- /dev/null +++ b/src/utils/env/index.js @@ -0,0 +1,43 @@ +/** + * Translates a Mongo env into an Envelope env + * @param {object} env - The site's env as it exists in Mongo + * @returns {Array} The array of Envelope env vars + */ +const translateFromMongoToEnvelope = (env = {}) => { + const envVars = Object.entries(env).map(([key, value]) => ({ + key, + scopes: ['builds', 'functions', 'runtime', 'post_processing'], + values: [ + { + context: 'all', + value, + }, + ], + })) + + return envVars +} + +/** + * Translates an Envelope env into a Mongo env + * @param {Array} envVars - The array of Envelope env vars + * @returns {object} The env object as compatible with Mongo + */ +const translateFromEnvelopeToMongo = (envVars = []) => + envVars + .sort((left, right) => (left.key.toLowerCase() < right.key.toLowerCase() ? -1 : 1)) + .reduce((acc, cur) => { + const envVar = cur.values.find((val) => ['dev', 'all'].includes(val.context)) + if (envVar && envVar.value) { + return { + ...acc, + [cur.key]: envVar.value, + } + } + return acc + }, {}) + +module.exports = { + translateFromMongoToEnvelope, + translateFromEnvelopeToMongo, +} diff --git a/src/utils/index.js b/src/utils/index.js index 0b3807a8dde..ca69f26537d 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -4,6 +4,7 @@ const createStreamPromise = require('./create-stream-promise') const deploy = require('./deploy') const detectServerSettings = require('./detect-server-settings') const dev = require('./dev') +const env = require('./env') const execa = require('./execa') const functions = require('./functions') const getGlobalConfig = require('./get-global-config') @@ -24,6 +25,7 @@ module.exports = { ...deploy, ...detectServerSettings, ...dev, + ...env, ...functions, ...getRepoData, ...ghAuth, diff --git a/tests/integration/650.command.envelope.test.js b/tests/integration/650.command.envelope.test.js new file mode 100644 index 00000000000..1d3a6829b77 --- /dev/null +++ b/tests/integration/650.command.envelope.test.js @@ -0,0 +1,420 @@ +const test = require('ava') + +const callCli = require('./utils/call-cli') +const { getCLIOptions, withMockApi } = require('./utils/mock-api') +const { withSiteBuilder } = require('./utils/site-builder') +const { normalize } = require('./utils/snapshots') + +const siteInfo = { + account_slug: 'test-account', + build_settings: { + env: {}, + }, + id: 'site_id', + name: 'site-name', + use_envelope: true, +} +const envelopeResponse = [ + { + key: 'EXISTING_VAR', + scopes: ['builds', 'functions', 'runtime', 'post_processing'], + values: [ + { + context: 'all', + value: 'envelope-value', + }, + ], + }, + { + key: 'OTHER_VAR', + scopes: ['builds', 'functions', 'runtime', 'post_processing'], + values: [ + { + context: 'all', + value: 'envelope-value', + }, + ], + }, +] +const routes = [ + { path: 'sites/site_id', response: siteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { + path: 'accounts', + response: [{ slug: siteInfo.account_slug }], + }, + { + path: 'accounts/test-account/env', + response: envelopeResponse, + }, + { + path: 'accounts/test-account/env', + method: 'POST', + response: {}, + }, + { + path: 'accounts/test-account/env/EXISTING_VAR', + method: 'PUT', + response: {}, + }, + { + path: 'accounts/test-account/env/EXISTING_VAR', + method: 'DELETE', + response: {}, + }, + { + path: 'accounts/test-account/env/OTHER_VAR', + method: 'DELETE', + response: {}, + }, +] + +test('env:set --json should create and return new var (with envelope)', async (t) => { + await withSiteBuilder('site-env', async (builder) => { + await builder.buildAsync() + + const finalEnv = { + EXISTING_VAR: 'envelope-value', + OTHER_VAR: 'envelope-value', + NEW_VAR: 'new-value', + } + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = await callCli( + ['env:set', '--json', 'NEW_VAR', 'new-value'], + getCLIOptions({ builder, apiUrl }), + true, + ) + + t.deepEqual(cliResponse, finalEnv) + }) + }) +}) + +test('env:set --json should update existing var (with envelope)', async (t) => { + await withSiteBuilder('site-env', async (builder) => { + await builder.buildAsync() + + const finalEnv = { + EXISTING_VAR: 'new-envelope-value', + OTHER_VAR: 'envelope-value', + } + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = await callCli( + ['env:set', '--json', 'EXISTING_VAR', 'new-envelope-value'], + getCLIOptions({ builder, apiUrl }), + true, + ) + + t.deepEqual(cliResponse, finalEnv) + }) + }) +}) + +test('env:import should throw error if file not exists (with envelope)', async (t) => { + await withSiteBuilder('site-env', async (builder) => { + await builder.buildAsync() + + await withMockApi(routes, async ({ apiUrl }) => { + await t.throwsAsync(() => callCli(['env:import', '.env'], getCLIOptions({ builder, apiUrl }))) + }) + }) +}) + +test('env:import --json should import new vars and override existing vars (with envelope)', async (t) => { + await withSiteBuilder('site-env', async (builder) => { + const finalEnv = { + EXISTING_VAR: 'from-dotenv', + OTHER_VAR: 'envelope-value', + NEW_VAR: 'from-dotenv', + } + + await builder + .withEnvFile({ + path: '.env', + env: { + EXISTING_VAR: 'from-dotenv', + NEW_VAR: 'from-dotenv', + }, + }) + .buildAsync() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = await callCli(['env:import', '--json', '.env'], getCLIOptions({ builder, apiUrl }), true) + + t.deepEqual(cliResponse, finalEnv) + }) + }) +}) + +test('env:set --json should be able to set var with empty value (with envelope)', async (t) => { + await withSiteBuilder('site-env', async (builder) => { + await builder.buildAsync() + + const finalEnv = { + EXISTING_VAR: '', + OTHER_VAR: 'envelope-value', + } + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = await callCli( + ['env:set', '--json', 'EXISTING_VAR', ''], + getCLIOptions({ builder, apiUrl }), + true, + ) + + t.deepEqual(cliResponse, finalEnv) + }) + }) +}) + +test('env:unset --json should remove existing variable (with envelope)', async (t) => { + await withSiteBuilder('site-env', async (builder) => { + await builder.buildAsync() + + const finalEnv = { + OTHER_VAR: 'envelope-value', + } + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = await callCli( + ['env:unset', '--json', 'EXISTING_VAR'], + getCLIOptions({ builder, apiUrl }), + true, + ) + + t.deepEqual(cliResponse, finalEnv) + }) + }) +}) + +test('env:import --json --replace-existing should replace all existing vars and return imported (with envelope)', async (t) => { + await withSiteBuilder('site-env', async (builder) => { + const finalEnv = { + EXISTING_VAR: 'from-dotenv', + NEW_VAR: 'from-dotenv', + } + + await builder + .withEnvFile({ + path: '.env', + env: { + EXISTING_VAR: 'from-dotenv', + NEW_VAR: 'from-dotenv', + }, + }) + .buildAsync() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = await callCli( + ['env:import', '--replaceExisting', '--json', '.env'], + getCLIOptions({ builder, apiUrl }), + true, + ) + + t.deepEqual(cliResponse, finalEnv) + }) + }) +}) + +test('env:migrate should return success message (mongo to envelope)', async (t) => { + const envFrom = { + MIGRATE_ME: 'migrate_me', + EXISTING_VAR: 'from', + } + + const siteInfoFrom = { + ...siteInfo, + id: 'site_id_a', + name: 'site-name-a', + build_settings: { env: envFrom }, + use_envelope: false, + } + + const siteInfoTo = { + ...siteInfo, + id: 'site_id_b', + name: 'site-name-b', + } + + const migrateRoutes = [ + { path: 'sites/site_id', response: siteInfo }, + { path: 'sites/site_id_a', response: siteInfoFrom }, + { path: 'sites/site_id_b', response: siteInfoTo }, + { path: 'sites/site_id/service-instances', response: [] }, + { + path: 'accounts', + response: [{ slug: siteInfo.account_slug }], + }, + { + path: 'accounts/test-account/env', + response: envelopeResponse, + }, + { + path: 'accounts/test-account/env', + method: 'POST', + response: {}, + }, + { + path: 'accounts/test-account/env/EXISTING_VAR', + method: 'DELETE', + response: {}, + }, + ] + + await withSiteBuilder('site-env', async (builder) => { + await builder.buildAsync() + await withMockApi(migrateRoutes, async ({ apiUrl, requests }) => { + const cliResponse = await callCli( + ['env:migrate', '--from', 'site_id_a', '--to', 'site_id_b'], + getCLIOptions({ apiUrl, builder }), + ) + + t.snapshot(normalize(cliResponse)) + + const deleteRequest = requests.find((request) => request.method === 'DELETE') + t.is(deleteRequest.path, '/api/v1/accounts/test-account/env/EXISTING_VAR') + + const postRequest = requests.find( + (request) => request.method === 'POST' && request.path === '/api/v1/accounts/test-account/env', + ) + + t.is(postRequest.body.length, 2) + t.is(postRequest.body[0].key, 'MIGRATE_ME') + t.is(postRequest.body[0].values[0].value, 'migrate_me') + t.is(postRequest.body[1].key, 'EXISTING_VAR') + t.is(postRequest.body[1].values[0].value, 'from') + }) + }) +}) + +test('env:migrate should return success message (envelope to mongo)', async (t) => { + const siteInfoFrom = { + ...siteInfo, + id: 'site_id_a', + name: 'site-name-a', + } + + const envTo = { + MIGRATE_ME: 'migrate_me', + EXISTING_VAR: 'to', + } + + const siteInfoTo = { + ...siteInfo, + id: 'site_id_b', + name: 'site-name-b', + build_settings: { env: envTo }, + use_envelope: false, + } + + const finalEnv = { + ...envTo, + EXISTING_VAR: 'envelope-value', + OTHER_VAR: 'envelope-value', + } + + const migrateRoutes = [ + { path: 'sites/site_id', response: siteInfo }, + { path: 'sites/site_id_a', response: siteInfoFrom }, + { path: 'sites/site_id_b', response: siteInfoTo }, + { path: 'sites/site_id/service-instances', response: [] }, + { + path: 'accounts', + response: [{ slug: siteInfo.account_slug }], + }, + { + path: 'accounts/test-account/env', + response: envelopeResponse, + }, + { + path: 'sites/site_id_b', + method: 'PATCH', + response: {}, + }, + ] + + await withSiteBuilder('site-env', async (builder) => { + await builder.buildAsync() + await withMockApi(migrateRoutes, async ({ apiUrl, requests }) => { + const cliResponse = await callCli( + ['env:migrate', '--from', 'site_id_a', '--to', 'site_id_b'], + getCLIOptions({ apiUrl, builder }), + ) + + t.snapshot(normalize(cliResponse)) + + const patchRequest = requests.find( + (request) => request.method === 'PATCH' && request.path === '/api/v1/sites/site_id_b', + ) + + t.deepEqual(patchRequest.body, { build_settings: { env: finalEnv } }) + }) + }) +}) + +test('env:migrate should return success message (envelope to envelope)', async (t) => { + const siteInfoFrom = { + ...siteInfo, + id: 'site_id_a', + name: 'site-name-a', + } + + const siteInfoTo = { + ...siteInfo, + id: 'site_id_b', + name: 'site-name-b', + } + + const migrateRoutes = [ + { path: 'sites/site_id', response: siteInfo }, + { path: 'sites/site_id_a', response: siteInfoFrom }, + { path: 'sites/site_id_b', response: siteInfoTo }, + { path: 'sites/site_id/service-instances', response: [] }, + { + path: 'accounts', + response: [{ slug: siteInfo.account_slug }], + }, + { + path: 'accounts/test-account/env', + response: envelopeResponse, + }, + { + path: 'accounts/test-account/env', + method: 'POST', + response: {}, + }, + { + path: 'accounts/test-account/env/EXISTING_VAR', + method: 'DELETE', + response: {}, + }, + { + path: 'accounts/test-account/env/OTHER_VAR', + method: 'DELETE', + response: {}, + }, + ] + + await withSiteBuilder('site-env', async (builder) => { + await builder.buildAsync() + await withMockApi(migrateRoutes, async ({ apiUrl, requests }) => { + const cliResponse = await callCli( + ['env:migrate', '--from', 'site_id_a', '--to', 'site_id_b'], + getCLIOptions({ apiUrl, builder }), + ) + + t.snapshot(normalize(cliResponse)) + + const deleteRequests = requests.filter((request) => request.method === 'DELETE') + t.is(deleteRequests.length, 2) + + const postRequest = requests.find((request) => request.method === 'POST') + t.deepEqual( + postRequest.body.map(({ key }) => key), + ['EXISTING_VAR', 'OTHER_VAR'], + ) + }) + }) +}) diff --git a/tests/integration/snapshots/650.command.envelope.test.js.md b/tests/integration/snapshots/650.command.envelope.test.js.md new file mode 100644 index 00000000000..5eda74b63a0 --- /dev/null +++ b/tests/integration/snapshots/650.command.envelope.test.js.md @@ -0,0 +1,23 @@ +# Snapshot report for `tests/integration/650.command.envelope.test.js` + +The actual snapshot is saved in `650.command.envelope.test.js.snap`. + +Generated by [AVA](https://avajs.dev). + +## env:migrate should return success message (mongo to envelope) + +> Snapshot 1 + + 'Successfully migrated environment variables from site-name-a to site-name-b' + +## env:migrate should return success message (envelope to mongo) + +> Snapshot 1 + + 'Successfully migrated environment variables from site-name-a to site-name-b' + +## env:migrate should return success message (envelope to envelope) + +> Snapshot 1 + + 'Successfully migrated environment variables from site-name-a to site-name-b' diff --git a/tests/integration/snapshots/650.command.envelope.test.js.snap b/tests/integration/snapshots/650.command.envelope.test.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..3cd3a15587fa78988789bc6ad732144cd3f8956a GIT binary patch literal 239 zcmVF00000000BUkU0**Knz6_AwYX8M2n&8mRz^7x(O%*eD={O2qZB$;8<0}40c%95Ui|s zjR)~CFl2m7Yt0VZffBy=N}=HKG|LuQntYxu^ZntIQAcBL@U2ma*TFkWPN2h}u{6X` p2XBG3P~p&0fvM?#mSc>6qABWaKZ)^fr12zgegU!C>A)fa000>qY+L{U literal 0 HcmV?d00001 diff --git a/tests/unit/utils/env/index.test.js b/tests/unit/utils/env/index.test.js new file mode 100644 index 00000000000..f558fee9c26 --- /dev/null +++ b/tests/unit/utils/env/index.test.js @@ -0,0 +1,107 @@ +const test = require('ava') + +const { translateFromEnvelopeToMongo, translateFromMongoToEnvelope } = require('../../../../src/utils/env') + +test('should translate from Mongo format to Envelope format when undefined', (t) => { + const env = translateFromMongoToEnvelope() + t.deepEqual(env, []) +}) + +test('should translate from Mongo format to Envelope format when empty object', (t) => { + const env = translateFromMongoToEnvelope({}) + t.deepEqual(env, []) +}) + +test('should translate from Mongo format to Envelope format with one env var', (t) => { + const env = translateFromMongoToEnvelope({ foo: 'bar' }) + t.deepEqual(env, [ + { + key: 'foo', + scopes: ['builds', 'functions', 'runtime', 'post_processing'], + values: [ + { + context: 'all', + value: 'bar', + }, + ], + }, + ]) +}) + +test('should translate from Mongo format to Envelope format with two env vars', (t) => { + const env = translateFromMongoToEnvelope({ foo: 'bar', baz: 'bang' }) + t.deepEqual(env, [ + { + key: 'foo', + scopes: ['builds', 'functions', 'runtime', 'post_processing'], + values: [ + { + context: 'all', + value: 'bar', + }, + ], + }, + { + key: 'baz', + scopes: ['builds', 'functions', 'runtime', 'post_processing'], + values: [ + { + context: 'all', + value: 'bang', + }, + ], + }, + ]) +}) + +test('should translate from Envelope format to Mongo format when undefined', (t) => { + const env = translateFromEnvelopeToMongo() + t.deepEqual(env, {}) +}) + +test('should translate from Envelope format to Mongo format when empty array', (t) => { + const env = translateFromEnvelopeToMongo([]) + t.deepEqual(env, {}) +}) + +test('should translate from Envelope format to Mongo format with one env var', (t) => { + const env = translateFromEnvelopeToMongo([ + { + key: 'foo', + scopes: ['builds', 'functions', 'runtime', 'post_processing'], + values: [ + { + context: 'all', + value: 'bar', + }, + ], + }, + ]) + t.deepEqual(env, { foo: 'bar' }) +}) + +test('should translate from Envelope format to Mongo format with two env vars', (t) => { + const env = translateFromEnvelopeToMongo([ + { + key: 'foo', + scopes: ['builds', 'functions', 'runtime', 'post_processing'], + values: [ + { + context: 'all', + value: 'bar', + }, + ], + }, + { + key: 'baz', + scopes: ['builds', 'functions', 'runtime', 'post_processing'], + values: [ + { + context: 'all', + value: 'bang', + }, + ], + }, + ]) + t.deepEqual(env, { foo: 'bar', baz: 'bang' }) +})