diff --git a/docs/commands/deploy.md b/docs/commands/deploy.md index 7021bd110e6..70fff47a3f2 100644 --- a/docs/commands/deploy.md +++ b/docs/commands/deploy.md @@ -33,6 +33,7 @@ netlify deploy - `context` (*string*) - Specify a deploy context for environment variables read during the build ("production", "deploy-preview", "branch-deploy", "dev") or `branch:your-branch` where `your-branch` is the name of a branch (default: dev) - `created-via` (*string*) - Specify the source of the deploy (e.g., "cli", "drop") - `dir` (*string*) - Specify a folder to deploy +- `env` (*string*) - Set an environment variable for this deploy only. Can be specified multiple times. - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `functions` (*string*) - Specify a functions folder to deploy - `json` (*boolean*) - Output deployment data as JSON @@ -43,6 +44,7 @@ netlify deploy - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `prod` (*boolean*) - Deploy to production +- `secret-env` (*string*) - Set a secret environment variable for this deploy only. The value is masked in the Netlify UI and API. Can be specified multiple times. - `site` (*string*) - A project name or ID to deploy to - `site-name` (*string*) - Name for a new site. Implies --create-site if the site does not already exist. - `skip-functions-cache` (*boolean*) - Ignore any functions created as part of a previous `build` or `deploy` commands, forcing them to be bundled again as part of the deployment @@ -63,6 +65,8 @@ netlify deploy --message "A message with an $ENV_VAR" netlify deploy --auth $NETLIFY_AUTH_TOKEN netlify deploy --trigger netlify deploy --context deploy-preview +netlify deploy --env "NODE_ENV=production" --env "API_URL=https://api.example.com" +netlify deploy --env "NODE_ENV=production" --secret-env "DATABASE_PASSWORD=$DB_PASSWORD" netlify deploy --site-name my-new-site --team my-team # Create site and deploy netlify deploy --allow-anonymous --dir ./public --no-build # Deploy without auth ``` diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 2fbcb3211b0..e3c96fcc55c 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -42,6 +42,7 @@ import { DEFAULT_CONCURRENT_HASH, DEFAULT_DEPLOY_TIMEOUT } from '../../utils/dep import { type DeployEvent, deploySite } from '../../utils/deploy/deploy-site.js' import { uploadSourceZip } from '../../utils/deploy/upload-source-zip.js' import { getEnvelopeEnv } from '../../utils/env/index.js' +import { mergeDeployEnvVars } from '../../utils/env/deploy-env-vars.js' import { getFunctionsManifestPath, getInternalFunctionsDir } from '../../utils/functions/index.js' import { isEmpty } from '../../utils/object-utilities.js' import openBrowser from '../../utils/open-browser.js' @@ -319,7 +320,18 @@ const generateDeployCommand = ( if (command?.options) { for (const option of command.options) { - if (['createSite', 'site', 'siteName', 'team'].includes(option.attributeName())) { + // `env` and `secretEnv` are skipped because reprinting a secret value here would leak it. + if ( + [ + 'createSite', + 'site', + 'siteName', + 'team', + // Don't print secret information + 'env', + 'secretEnv', + ].includes(option.attributeName()) + ) { continue } @@ -665,6 +677,7 @@ const runDeploy = async ({ manifestPath, skipFunctionsCache, siteRoot: site.root, + environment: mergeDeployEnvVars(options.env, options.secretEnv), }) } catch (error) { if (deployId) { @@ -1332,6 +1345,13 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand) ) } } else { + if (options.env != null || options.secretEnv != null) { + return logAndThrowError( + `${chalk.cyanBright('--env')} and ${chalk.cyanBright( + '--secret-env', + )} require an account. Log in, or deploy without them.`, + ) + } return anonymousDeploy(options, command) } } diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts index ee1e050f5f0..82242166b26 100644 --- a/src/commands/deploy/index.ts +++ b/src/commands/deploy/index.ts @@ -4,6 +4,7 @@ import { Option } from 'commander' import terminalLink from 'terminal-link' import { normalizeContext } from '../../utils/env/index.js' +import { findDuplicateKey, mergeDeployEnvVars, parseDeployEnvVar } from '../../utils/env/deploy-env-vars.js' import BaseCommand from '../base-command.js' import { chalk, logAndThrowError, warn } from '../../utils/command-helpers.js' import type { DeployOptionValues } from './option_values.js' @@ -76,6 +77,16 @@ For detailed configuration options, see the Netlify documentation.`, 'Specify a deploy context for environment variables read during the build ("production", "deploy-preview", "branch-deploy", "dev") or `branch:your-branch` where `your-branch` is the name of a branch (default: dev)', normalizeContext, ) + .option( + '--env ', + 'Set an environment variable for this deploy only. Can be specified multiple times.', + parseDeployEnvVar('--env'), + ) + .option( + '--secret-env ', + 'Set a secret environment variable for this deploy only. The value is masked in the Netlify UI and API. Can be specified multiple times.', + parseDeployEnvVar('--secret-env'), + ) .option( '--skip-functions-cache', 'Ignore any functions created as part of a previous `build` or `deploy` commands, forcing them to be bundled again as part of the deployment', @@ -110,6 +121,8 @@ For detailed configuration options, see the Netlify documentation.`, 'netlify deploy --auth $NETLIFY_AUTH_TOKEN', 'netlify deploy --trigger', 'netlify deploy --context deploy-preview', + 'netlify deploy --env "NODE_ENV=production" --env "API_URL=https://api.example.com"', + 'netlify deploy --env "NODE_ENV=production" --secret-env "DATABASE_PASSWORD=$DB_PASSWORD"', 'netlify deploy --site-name my-new-site --team my-team # Create site and deploy', 'netlify deploy --allow-anonymous --dir ./public --no-build # Deploy without auth', ]) @@ -136,6 +149,17 @@ For more information about Netlify deploys, see ${terminalLink(docsUrl, docsUrl, return logAndThrowError('--context flag is only available when using the --build flag') } + if (options.env != null || options.secretEnv != null) { + if (options.trigger) { + return logAndThrowError('--env and --secret-env cannot be used with --trigger') + } + + const duplicateKey = findDuplicateKey(mergeDeployEnvVars(options.env, options.secretEnv)) + if (duplicateKey != null) { + return logAndThrowError(`Environment variable "${duplicateKey}" was specified more than once.`) + } + } + if (options.siteName) { if (options.site) { return logAndThrowError( diff --git a/src/commands/deploy/option_values.ts b/src/commands/deploy/option_values.ts index e8384f9fa4e..2f74d76999b 100644 --- a/src/commands/deploy/option_values.ts +++ b/src/commands/deploy/option_values.ts @@ -1,6 +1,7 @@ // This type lives in a separate file to prevent import cycles. import type { BaseOptionValues } from '../base-command.js' +import type { DeployEnvironmentVariable } from '../../utils/env/deploy-env-vars.js' export type DeployOptionValues = BaseOptionValues & { alias?: string @@ -12,12 +13,14 @@ export type DeployOptionValues = BaseOptionValues & { createSite?: string | boolean dir?: string draft: boolean + env?: DeployEnvironmentVariable[] functions?: string json: boolean message?: string open: boolean prod: boolean prodIfUnlocked: boolean + secretEnv?: DeployEnvironmentVariable[] site?: string siteName?: string skipFunctionsCache: boolean diff --git a/src/utils/deploy/deploy-site.ts b/src/utils/deploy/deploy-site.ts index a28b333e65a..be93969d42d 100644 --- a/src/utils/deploy/deploy-site.ts +++ b/src/utils/deploy/deploy-site.ts @@ -28,6 +28,7 @@ import { import uploadFiles from './upload-files.js' import { getUploadList, waitForDeploy, waitForDiff } from './util.js' import type { DeployEvent } from './status-cb.js' +import type { DeployEnvironmentVariable } from '../env/deploy-env-vars.js' import { temporaryDirectory } from '../temporary-file.js' export type { DeployEvent } @@ -59,6 +60,7 @@ export const deploySite = async ( deployId, deployTimeout = DEFAULT_DEPLOY_TIMEOUT, draft = false, + environment, // @ts-expect-error TS(2525) FIXME: Initializer provides no value for this binding ele... Remove this comment to see the full error message filter, fnDir = [], @@ -84,6 +86,7 @@ export const deploySite = async ( concurrentUpload?: number deployTimeout?: number draft?: boolean + environment?: DeployEnvironmentVariable[] maxRetry?: number statusCb?: (status: DeployEvent) => void syncFileLimit?: number @@ -178,7 +181,7 @@ For more information, visit https://ntl.fyi/cli-native-modules.`) const primaryFramework = packageFrameworks?.[0] // @ts-expect-error TS(2349) This expression is not callable - const deployParams = cleanDeep({ + const cleanedParams = cleanDeep({ siteId, deploy_id: deployId, body: { @@ -195,6 +198,12 @@ For more information, visit https://ntl.fyi/cli-native-modules.`) build_version: getNetlifyBuildVersion(), }, }) + // cleanDeep deeply strips keys with empty strings, but empty strings are valid environment + // variable values--a user can use an empty string to e.g. unset a variable only for a deploy. + // This would result in payloads with a missing `value` key, which the API would reject. + const deployParams = environment?.length + ? { ...cleanedParams, body: { ...cleanedParams.body, environment } } + : cleanedParams let deploy = await api.updateSiteDeploy(deployParams) if (deployParams.body.async) deploy = await waitForDiff(api, deploy.id, siteId, deployTimeout) diff --git a/src/utils/env/deploy-env-vars.ts b/src/utils/env/deploy-env-vars.ts new file mode 100644 index 00000000000..cb489c9a274 --- /dev/null +++ b/src/utils/env/deploy-env-vars.ts @@ -0,0 +1,125 @@ +import { InvalidArgumentError } from 'commander' + +export interface DeployEnvironmentVariable { + key: string + value: string + is_secret: boolean + scopes: ['functions'] +} + +const MAX_KEY_LENGTH = 255 +const VALID_KEY_NAME = /^[a-zA-Z][a-zA-Z0-9_]*$/ + +const RESERVED_KEY_NAMES = new Set([ + // AWS-specific env vars + 'AWS_REGION', + 'AWS_EXECUTION_ENV', + 'AWS_LAMBDA_FUNCTION_NAME', + 'AWS_LAMBDA_FUNCTION_MEMORY_SIZE', + 'AWS_LAMBDA_FUNCTION_VERSION', + 'AWS_LAMBDA_LOG_GROUP_NAME', + 'AWS_LAMBDA_LOG_STREAM_NAME', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SESSION_TOKEN', + 'AWS_LAMBDA_RUNTIME_API', + 'NETLIFY', + 'BUILD_ID', + 'CONTEXT', + 'REPOSITORY_URL', + 'BRANCH', + 'HEAD', + 'COMMIT_REF', + 'CACHED_COMMIT_REF', + 'PULL_REQUEST', + 'REVIEW_ID', + 'URL', + 'DEPLOY_URL', + 'DEPLOY_PRIME_URL', + 'DEPLOY_ID', + 'SITE_NAME', + 'SITE_ID', + 'NETLIFY_IMAGES_CDN_DOMAIN', + 'INCOMING_HOOK_TITLE', + 'INCOMING_HOOK_URL', + 'INCOMING_HOOK_BODY', +]) + +const validateKey = (key: string): void => { + if (key.length > MAX_KEY_LENGTH) { + throw new InvalidArgumentError(`Key names should be ${MAX_KEY_LENGTH.toString()} characters or less.`) + } + if (!VALID_KEY_NAME.test(key)) { + throw new InvalidArgumentError( + 'Key names must start with a letter and can only consist of alphanumeric characters and underscores', + ) + } + if (RESERVED_KEY_NAMES.has(key.toUpperCase())) { + throw new InvalidArgumentError(`${key} is a reserved key name`) + } +} + +export type DeployEnvVarFlag = '--env' | '--secret-env' + +/** + * Builds a Commander argument parser that accumulates `KEY=VALUE` arguments into a list of + * deploy-scoped environment variables. This lets the flag be repeated. + * + * Throws `InvalidArgumentError` if the argument is not in `KEY=VALUE` format, or if the key would + * be rejected by Envelope. + */ +// (TODO(ndhoule): Ideally we'd let the API call perform this validation and return an +// error, but it currently does not.) +export const parseDeployEnvVar = + (flag: DeployEnvVarFlag) => + (arg: string, previous: DeployEnvironmentVariable[] = []): DeployEnvironmentVariable[] => { + const separatorIndex = arg.indexOf('=') + if (separatorIndex === -1) { + throw new InvalidArgumentError(`Invalid ${flag} value "${arg}". Expected KEY=VALUE.`) + } + + const key = arg.slice(0, separatorIndex) + if (key === '') { + throw new InvalidArgumentError(`Invalid ${flag} value "${arg}". Expected KEY=VALUE.`) + } + validateKey(key) + + return [ + ...previous, + { + key, + value: arg.slice(separatorIndex + 1), + is_secret: flag === '--secret-env', + // Deploy-scoped variables only take effect in the functions scope. + scopes: ['functions'], + }, + ] + } + +/** + * Combines the variables collected by `--env` and `--secret-env` into the single list the API + * expects. + */ +export const mergeDeployEnvVars = ( + env: DeployEnvironmentVariable[] = [], + secretEnv: DeployEnvironmentVariable[] = [], +): DeployEnvironmentVariable[] => [...env, ...secretEnv] + +/** + * Returns the first key that appears more than once, or `undefined` if all keys are unique. + * + * The API rejects duplicate keys, and a single flag's parser cannot see the other flag's values, + * so callers must check the merged list. + */ +export const findDuplicateKey = (variables: DeployEnvironmentVariable[]): string | undefined => { + const seen = new Set() + + for (const { key } of variables) { + if (seen.has(key)) { + return key + } + seen.add(key) + } + + return undefined +} diff --git a/tests/integration/commands/deploy/deploy-api-routes.ts b/tests/integration/commands/deploy/deploy-api-routes.ts index cebc30ac90f..8d5696c4c24 100644 --- a/tests/integration/commands/deploy/deploy-api-routes.ts +++ b/tests/integration/commands/deploy/deploy-api-routes.ts @@ -27,6 +27,13 @@ const deployResponse = { url: 'https://test-site.netlify.app', } +interface DeployEnvironmentVariable { + key: string + value: string + is_secret: boolean + scopes: string[] +} + interface DeployBody { files?: Record functions?: Record @@ -35,13 +42,22 @@ interface DeployBody { async?: boolean branch?: string draft?: boolean + environment?: DeployEnvironmentVariable[] framework?: string framework_version?: string build_version?: string } +interface CreateDeployBody { + draft?: boolean + branch?: string + environment?: DeployEnvironmentVariable[] + deploy_source?: string +} + export interface DeployRouteState { getDeployBody: () => DeployBody | null + getCreateDeployBody: () => CreateDeployBody | null getUploadedFiles: () => Record getUploadedFunctions: () => Record reset: () => void @@ -49,6 +65,7 @@ export interface DeployRouteState { export const createDeployRoutes = (): { routes: Route[] } & DeployRouteState => { let lastDeployBody: DeployBody | null = null + let lastCreateDeployBody: CreateDeployBody | null = null let uploadedFiles: Record = {} let uploadedFunctions: Record = {} @@ -68,7 +85,8 @@ export const createDeployRoutes = (): { routes: Route[] } & DeployRouteState => { path: 'sites/site_id/deploys', method: 'POST', - response: (_req: express.Request, res: express.Response) => { + response: (req: express.Request, res: express.Response) => { + lastCreateDeployBody = req.body as CreateDeployBody res.json({ ...deployResponse, state: 'prepared', @@ -161,10 +179,12 @@ export const createDeployRoutes = (): { routes: Route[] } & DeployRouteState => return { routes, getDeployBody: () => lastDeployBody, + getCreateDeployBody: () => lastCreateDeployBody, getUploadedFiles: () => uploadedFiles, getUploadedFunctions: () => uploadedFunctions, reset: () => { lastDeployBody = null + lastCreateDeployBody = null uploadedFiles = {} uploadedFunctions = {} }, diff --git a/tests/integration/commands/deploy/deploy.test.ts b/tests/integration/commands/deploy/deploy.test.ts index d14d7139f43..efcf72613ab 100644 --- a/tests/integration/commands/deploy/deploy.test.ts +++ b/tests/integration/commands/deploy/deploy.test.ts @@ -1734,4 +1734,88 @@ describe.concurrent('deploy command', () => { }) }) }) + + describe('deploy-scoped environment variables', () => { + const withDeployedEnv = async ( + t: Parameters>[0], + args: string[], + assert: (deployState: DeployRouteState) => void, + ) => { + await withMockDeploy(async (mockApi, deployState) => { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'public/index.html', content: '

env vars

' }) + await builder.build() + + await callCli( + ['deploy', '--json', '--no-build', '--dir', 'public', ...args], + getCLIOptions({ apiUrl: mockApi.apiUrl, builder }), + ).then(parseDeploy) + + assert(deployState) + }) + }) + } + + test('preserves empty-string variable values', async (t) => { + await withDeployedEnv(t, ['--env', 'EMPTY='], (deployState) => { + expect(deployState.getDeployBody()!.environment).toEqual([ + { key: 'EMPTY', value: '', is_secret: false, scopes: ['functions'] }, + ]) + }) + }) + + test('sends --env and --secret-env on the update-deploy API call', async (t) => { + await withDeployedEnv( + t, + ['--env', 'NODE_ENV=production', '--env', 'API_URL=https://example.com/?a=b', '--secret-env', 'TOKEN=hunter2'], + (deployState) => { + expect(deployState.getDeployBody()!.environment).toEqual([ + { key: 'NODE_ENV', value: 'production', is_secret: false, scopes: ['functions'] }, + { key: 'API_URL', value: 'https://example.com/?a=b', is_secret: false, scopes: ['functions'] }, + { key: 'TOKEN', value: 'hunter2', is_secret: true, scopes: ['functions'] }, + ]) + }, + ) + }) + + test('does not send environment on the create-deploy request', async (t) => { + await withDeployedEnv(t, ['--env', 'NODE_ENV=production'], (deployState) => { + expect(deployState.getCreateDeployBody()).not.toBeNull() + expect(deployState.getCreateDeployBody()!.environment).toBeUndefined() + }) + }) + + test('omits environment entirely when neither flag is used', async (t) => { + await withDeployedEnv(t, [], (deployState) => { + expect(deployState.getDeployBody()!.environment).toBeUndefined() + }) + }) + + for (const { name, args, message } of [ + { name: 'a value without a separator', args: ['--env', 'NODE_ENV'], message: 'Expected KEY=VALUE' }, + { name: 'a reserved key', args: ['--env', 'SITE_ID=abc'], message: 'is a reserved key name' }, + { name: 'a malformed key', args: ['--env', '2FA=on'], message: 'must start with a letter' }, + { + name: 'a duplicate key', + args: ['--env', 'A=1', '--secret-env', 'A=2'], + message: 'was specified more than once', + }, + ]) { + test(`rejects ${name}`, async (t) => { + await withMockDeploy(async (mockApi) => { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'public/index.html', content: '

env vars

' }) + await builder.build() + + await expect( + callCli( + ['deploy', '--no-build', '--dir', 'public', ...args], + getCLIOptions({ apiUrl: mockApi.apiUrl, builder }), + ), + ).rejects.toHaveProperty('stderr', expect.stringContaining(message)) + }) + }) + }) + } + }) }) diff --git a/tests/unit/utils/env/deploy-env-vars.test.ts b/tests/unit/utils/env/deploy-env-vars.test.ts new file mode 100644 index 00000000000..888b4bef08c --- /dev/null +++ b/tests/unit/utils/env/deploy-env-vars.test.ts @@ -0,0 +1,96 @@ +import { InvalidArgumentError } from 'commander' +import { describe, expect, test } from 'vitest' + +import { findDuplicateKey, mergeDeployEnvVars, parseDeployEnvVar } from '../../../../src/utils/env/deploy-env-vars.js' + +const parseEnv = parseDeployEnvVar('--env') +const parseSecretEnv = parseDeployEnvVar('--secret-env') + +describe('parseDeployEnvVar', () => { + test('parses KEY=VALUE into the shape the API expects', () => { + expect(parseEnv('NODE_ENV=production')).toEqual([ + { key: 'NODE_ENV', value: 'production', is_secret: false, scopes: ['functions'] }, + ]) + }) + + test('marks values from --secret-env as secret', () => { + expect(parseSecretEnv('DATABASE_PASSWORD=hunter2')).toEqual([ + { key: 'DATABASE_PASSWORD', value: 'hunter2', is_secret: true, scopes: ['functions'] }, + ]) + }) + + test('preserves `=` inside variable values', () => { + expect(parseEnv('API_URL=https://example.com/?a=b&c=d')).toEqual([ + { key: 'API_URL', value: 'https://example.com/?a=b&c=d', is_secret: false, scopes: ['functions'] }, + ]) + }) + + test('accepts an empty value', () => { + expect(parseEnv('EMPTY=')).toEqual([{ key: 'EMPTY', value: '', is_secret: false, scopes: ['functions'] }]) + }) + + test('accumulates repeated flags', () => { + const first = parseEnv('A=1') + const second = parseEnv('B=2', first) + + expect(second).toEqual([ + { key: 'A', value: '1', is_secret: false, scopes: ['functions'] }, + { key: 'B', value: '2', is_secret: false, scopes: ['functions'] }, + ]) + }) + + test.each([ + ['no separator', 'NODE_ENV'], + ['empty key', '=production'], + ])('rejects a value with %s', (_, arg) => { + expect(() => parseEnv(arg)).toThrow(InvalidArgumentError) + expect(() => parseEnv(arg)).toThrow(`Invalid --env value "${arg}". Expected KEY=VALUE.`) + }) + + test.each([ + ['a reserved key', 'SITE_ID=abc'], + ['a reserved key in lowercase', 'site_id=abc'], + ])('rejects %s', (_, arg) => { + expect(() => parseEnv(arg)).toThrow(/is a reserved key name/) + }) + + test.each([ + ['starting with a digit', '2FA=on'], + ['containing a hyphen', 'MY-VAR=1'], + ['containing a dot', 'MY.VAR=1'], + ])('rejects a key %s', (_, arg) => { + expect(() => parseEnv(arg)).toThrow(/must start with a letter/) + }) + + test('rejects a key longer than 255 characters', () => { + expect(() => parseEnv(`${'A'.repeat(256)}=1`)).toThrow('Key names should be 255 characters or less.') + expect(parseEnv(`${'A'.repeat(255)}=1`)).toHaveLength(1) + }) +}) + +describe('mergeDeployEnvVars', () => { + test('returns an empty list when neither flag was used', () => { + expect(mergeDeployEnvVars()).toEqual([]) + }) + + test('concatenates plain and secret variables', () => { + expect(mergeDeployEnvVars(parseEnv('A=1'), parseSecretEnv('B=2'))).toEqual([ + { key: 'A', value: '1', is_secret: false, scopes: ['functions'] }, + { key: 'B', value: '2', is_secret: true, scopes: ['functions'] }, + ]) + }) +}) + +describe('findDuplicateKey', () => { + test('returns undefined when all keys are unique', () => { + expect(findDuplicateKey(mergeDeployEnvVars(parseEnv('A=1'), parseSecretEnv('B=2')))).toBeUndefined() + }) + + test('finds a key repeated across --env and --secret-env', () => { + expect(findDuplicateKey(mergeDeployEnvVars(parseEnv('A=1'), parseSecretEnv('A=2')))).toBe('A') + }) + + test('finds a key repeated within a single flag', () => { + expect(findDuplicateKey(parseEnv('A=2', parseEnv('A=1')))).toBe('A') + }) +})