Skip to content

Commit 293c047

Browse files
committed
feat(build): add utils.deploy.env.add build plugin API
This changeset adds a new utility to build plugins: `utils.deploy.env.add()`. This utility function allows build plugins to register environment variables at build time that will be injected into a specific deploy. Userland build plugins cannot see or manipulate the deploy environment variables registered by core or other userland plugins. Deploy-specific environment variables are collected by the `build` supervisor process and processed by the deploy step, which sends them to Buildbot for processing. They are also returned by the programmatic `dev` interface; we'll use these returned variables in the CLI, where we'll e.g. set them on functions. While I was in here I also converted a few files to TypeScript/generally did my best to improve the types along the way.
1 parent 654f317 commit 293c047

File tree

34 files changed

+402
-130
lines changed

34 files changed

+402
-130
lines changed

packages/build/src/core/build.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,7 @@ const runBuild = async function ({
660660
configMutations,
661661
metrics,
662662
returnValues,
663+
deployEnvVars,
663664
} = await runSteps({
664665
steps,
665666
buildbotServerSocket,
@@ -708,5 +709,6 @@ const runBuild = async function ({
708709
configMutations,
709710
metrics,
710711
returnValues,
712+
deployEnvVars,
711713
}
712714
}

packages/build/src/core/dev.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const startDev = async (devCommand, flags = {}) => {
1313
netlifyConfig: netlifyConfigA,
1414
configMutations,
1515
returnValues,
16+
deployEnvVars,
1617
} = await execBuild({
1718
...normalizedFlags,
1819
errorMonitor,
@@ -33,6 +34,7 @@ export const startDev = async (devCommand, flags = {}) => {
3334
logs,
3435
configMutations,
3536
generatedFunctions: getGeneratedFunctions(returnValues),
37+
deployEnvVars,
3638
}
3739
} catch (error) {
3840
const { severity, message, stack } = await handleBuildError(error, errorParams)
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import memoizeOne from 'memoize-one'
22

33
// Add a `object[propName]` whose value is the return value of `getFunc()`, but
44
// is only retrieved when accessed.
5-
export const addLazyProp = function (object, propName, getFunc) {
6-
const mGetFunc = memoizeOne(getFunc, returnTrue)
7-
// Mutation is required due to the usage of `Object.defineProperty()`
5+
export const addLazyProp = function (object: object, propName: string, getFunc: () => unknown): void {
6+
// @ts-expect-error(ndhoule): dis be angry
7+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
8+
const mGetFunc: typeof getFunc = memoizeOne(getFunc, returnTrue)
89

10+
// Mutation is required due to the usage of `Object.defineProperty()`
911
Object.defineProperty(object, propName, {
1012
get: mGetFunc,
1113
enumerable: true,

packages/build/src/plugins/child/run.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ export const run = async function (
2020
const method = methods[event]
2121
const runState = {}
2222
const generatedFunctions = []
23+
const deployEnvVars = []
2324
const systemLog = getSystemLog()
24-
const utils = getUtils({ event, constants, generatedFunctions, runState })
25+
const utils = getUtils({ event, constants, deployEnvVars, generatedFunctions, runState })
2526
const netlifyConfigCopy = cloneNetlifyConfig(netlifyConfig)
2627
const runOptions = {
2728
utils,
@@ -45,6 +46,6 @@ export const run = async function (
4546

4647
const configMutations = getConfigMutations(netlifyConfig, netlifyConfigCopy, event)
4748
const returnValue = generatedFunctions.length ? { generatedFunctions } : undefined
48-
return { ...runState, newEnvChanges, configMutations, returnValue }
49+
return { ...runState, deployEnvVars, newEnvChanges, configMutations, returnValue }
4950
})
5051
}

packages/build/src/plugins/child/status.js renamed to packages/build/src/plugins/child/status.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,32 @@ import isPlainObj from 'is-plain-obj'
33
import { addErrorInfo } from '../../error/info.js'
44

55
// Report status information to the UI
6-
export const show = function (runState, showArgs) {
6+
export const show = function (runState: Record<string, unknown>, showArgs: Record<string, unknown>) {
77
validateShowArgs(showArgs)
88
const { title, summary, text, extraData } = removeEmptyStrings(showArgs)
99
runState.status = { state: 'success', title, summary, text, extraData }
1010
}
1111

1212
// Validate arguments of `utils.status.show()`
13-
const validateShowArgs = function (showArgs) {
13+
const validateShowArgs = function (showArgs: Record<string, unknown>) {
1414
try {
1515
validateShowArgsObject(showArgs)
1616
const { title, summary, text, extraData, ...otherArgs } = showArgs
1717
validateShowArgsKeys(otherArgs)
1818
Object.entries({ title, summary, text }).forEach(validateStringArg)
1919
validateShowArgsSummary(summary)
2020
validateShowArgsExtraData(extraData)
21-
} catch (error) {
21+
} catch (error: unknown) {
22+
if (!(error instanceof Error)) {
23+
throw error
24+
}
2225
error.message = `utils.status.show() ${error.message}`
2326
addErrorInfo(error, { type: 'pluginValidation' })
2427
throw error
2528
}
2629
}
2730

28-
const validateShowArgsObject = function (showArgs) {
31+
function validateShowArgsObject(showArgs: unknown): asserts showArgs is Record<string, unknown> {
2932
if (showArgs === undefined) {
3033
throw new Error('requires an argument')
3134
}
@@ -35,36 +38,36 @@ const validateShowArgsObject = function (showArgs) {
3538
}
3639
}
3740

38-
const validateShowArgsKeys = function (otherArgs) {
41+
const validateShowArgsKeys = function (otherArgs: Record<string, unknown>) {
3942
const otherKeys = Object.keys(otherArgs).map((arg) => `"${arg}"`)
4043
if (otherKeys.length !== 0) {
4144
throw new Error(`must only contain "title", "summary" or "text" properties, not ${otherKeys.join(', ')}`)
4245
}
4346
}
4447

45-
const validateStringArg = function ([key, value]) {
48+
const validateStringArg = function ([key, value]: [string, unknown]) {
4649
if (value !== undefined && typeof value !== 'string') {
4750
throw new Error(`"${key}" property must be a string`)
4851
}
4952
}
5053

51-
const validateShowArgsSummary = function (summary) {
52-
if (summary === undefined || summary.trim() === '') {
54+
const validateShowArgsSummary = function (summary: unknown) {
55+
if (typeof summary !== 'string' || summary.trim() === '') {
5356
throw new Error('requires specifying a "summary" property')
5457
}
5558
}
5659

57-
const validateShowArgsExtraData = function (extraData) {
58-
if (extraData !== undefined && Array.isArray(extraData) === false) {
60+
const validateShowArgsExtraData = function (extraData: unknown) {
61+
if (extraData !== undefined && !Array.isArray(extraData)) {
5962
throw new TypeError('provided extra data must be an array')
6063
}
6164
}
6265

63-
const removeEmptyStrings = function (showArgs) {
64-
return Object.fromEntries(Object.entries(showArgs).map(removeEmptyString))
66+
const removeEmptyStrings = function (showArgs: Record<string, unknown>): Record<string, unknown> {
67+
return Object.fromEntries(Object.entries(showArgs).map(removeEmptyString)) as Record<string, unknown>
6568
}
6669

67-
const removeEmptyString = function ([key, value]) {
70+
const removeEmptyString = function ([key, value]: [string, unknown]) {
6871
if (typeof value === 'string' && value.trim() === '') {
6972
return [key]
7073
}

packages/build/src/plugins/child/utils.js

Lines changed: 0 additions & 59 deletions
This file was deleted.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { bindOpts as cacheBindOpts } from '@netlify/cache-utils'
2+
import { add as functionsAdd, list as functionsList, listAll as functionsListAll } from '@netlify/functions-utils'
3+
import { getGitUtils } from '@netlify/git-utils'
4+
import { run as baseRun, runCommand } from '@netlify/run-utils'
5+
6+
import { failBuild, failPlugin, cancelBuild, failPluginWithWarning } from '../error.js'
7+
import { isSoftFailEvent } from '../events.js'
8+
import { isReservedEnvironmentVariableKey } from '../../utils/environment.js'
9+
import type { NetlifyPluginConstants } from '../../core/constants.js'
10+
import type { ReturnValue } from '../../steps/return_values.ts'
11+
import type { NetlifyPluginUtils } from '../../types/options/netlify_plugin_utils.js'
12+
13+
import { addLazyProp } from './lazy.js'
14+
import { show } from './status.js'
15+
16+
type BuildEvent = 'onPreBuild' | 'onBuild' | 'onPostBuild' | 'onError' | 'onSuccess' | 'onEnd'
17+
18+
type RunState = Record<string, unknown>
19+
20+
type DeployEnvVarsData = { key: string; value: string; isSecret: boolean }[]
21+
22+
// Retrieve the `utils` argument.
23+
export const getUtils = function ({
24+
event,
25+
constants: { FUNCTIONS_SRC, INTERNAL_FUNCTIONS_SRC, CACHE_DIR },
26+
generatedFunctions = [],
27+
runState,
28+
deployEnvVars = [],
29+
}: {
30+
event: BuildEvent
31+
constants: NetlifyPluginConstants
32+
generatedFunctions?: ReturnValue['generatedFunctions']
33+
runState: RunState
34+
deployEnvVars: DeployEnvVarsData
35+
}): NetlifyPluginUtils {
36+
const run = Object.assign(baseRun, { command: runCommand }) as unknown as NetlifyPluginUtils['run']
37+
const build = getBuildUtils(event)
38+
const cache = getCacheUtils(CACHE_DIR)
39+
const deploy = getDeployUtils({ deployEnvVars })
40+
const functions = getFunctionsUtils(FUNCTIONS_SRC, INTERNAL_FUNCTIONS_SRC, generatedFunctions)
41+
const status = getStatusUtils(runState)
42+
const utils = { build, cache, deploy, functions, run, status }
43+
addLazyProp(utils, 'git', () => getGitUtils())
44+
return utils as typeof utils & { git: NetlifyPluginUtils['git'] }
45+
}
46+
47+
const getDeployUtils = ({ deployEnvVars }: { deployEnvVars: DeployEnvVarsData }): NetlifyPluginUtils['deploy'] => {
48+
const env: NetlifyPluginUtils['deploy']['env'] = {
49+
add(key, value, { isSecret = false, scopes = [] } = {}) {
50+
// Do some basic client-side validation on the injected variables. The build happens long
51+
// before we attempt to create these environment variables via the Netlify API. We want to
52+
// give the user as much immediate feedback as possible, so we perform this validation in the
53+
// build plugin as to direct the user on which build plugin is causing a problem.
54+
if (isReservedEnvironmentVariableKey(key)) {
55+
throw new Error(
56+
`utils.deploy.env.add() failed: "${key}" is a reserved environment variable in Netlify deployments. Use a different environment variable key.`,
57+
)
58+
}
59+
60+
let normalizedScopes = new Set(...scopes)
61+
if (normalizedScopes.size == 0) {
62+
normalizedScopes = new Set(
63+
// If the user did not specify scopes, we assume they mean all valid scopes. Secrets are
64+
// not permitted in the post-processing scope.
65+
isSecret ? ['builds', 'functions', 'runtime'] : ['builds', 'functions', 'post_processing', 'runtime'],
66+
)
67+
}
68+
if (isSecret && normalizedScopes.has('post_processing')) {
69+
throw new Error(`utils.deploy.env.add() failed: The "post_processing" scope cannot be used with isSecret=true.`)
70+
}
71+
72+
const existingDeployEnvVarIdx = deployEnvVars.findIndex((env) => env.key === key)
73+
if (existingDeployEnvVarIdx !== -1) {
74+
deployEnvVars[existingDeployEnvVarIdx] = { key, value, isSecret }
75+
} else {
76+
deployEnvVars.push({ key, value, isSecret })
77+
}
78+
79+
return env
80+
},
81+
}
82+
return Object.freeze({ env: Object.freeze(env) })
83+
}
84+
85+
const getBuildUtils = function (event: 'onPreBuild' | 'onBuild' | 'onPostBuild' | 'onError' | 'onSuccess' | 'onEnd') {
86+
if (isSoftFailEvent(event)) {
87+
return {
88+
failPlugin,
89+
failBuild: failPluginWithWarning.bind(null, 'failBuild', event),
90+
cancelBuild: failPluginWithWarning.bind(null, 'cancelBuild', event),
91+
}
92+
}
93+
94+
return { failBuild, failPlugin, cancelBuild }
95+
}
96+
97+
const getCacheUtils = function (CACHE_DIR: string) {
98+
return cacheBindOpts({ cacheDir: CACHE_DIR })
99+
}
100+
101+
const getFunctionsUtils = function (
102+
FUNCTIONS_SRC: string | undefined,
103+
INTERNAL_FUNCTIONS_SRC: string | undefined,
104+
generatedFunctions: { path: string }[],
105+
) {
106+
const functionsDirectories = [INTERNAL_FUNCTIONS_SRC, FUNCTIONS_SRC].filter(Boolean)
107+
const add = (src: string | undefined) => functionsAdd(src, INTERNAL_FUNCTIONS_SRC, { fail: failBuild })
108+
const list = functionsList.bind(null, functionsDirectories, { fail: failBuild })
109+
const listAll = functionsListAll.bind(null, functionsDirectories, { fail: failBuild })
110+
const generate = (functionPath: string) => generatedFunctions.push({ path: functionPath })
111+
112+
/** @type import('../../types/options/netlify_plugin_functions_util.js').NetlifyPluginFunctionsUtil */
113+
return { add, list, listAll, generate }
114+
}
115+
116+
const getStatusUtils = function (runState) {
117+
return { show: show.bind(undefined, runState) }
118+
}
Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,47 @@ import { logFailPluginWarning } from '../log/messages/plugins.js'
44
// Stop build.
55
// As opposed to throwing an error directly or to uncaught exceptions, this is
66
// displayed as a user error, not an implementation error.
7-
export const failBuild = function (message, opts) {
7+
export const failBuild = function (message: string, opts?: { error: Error; errorMetadata: string[] }) {
88
throw normalizeError('failBuild', failBuild, message, opts)
99
}
1010

1111
// Stop plugin. Same as `failBuild` but only stops plugin not whole build
12-
export const failPlugin = function (message, opts) {
12+
export const failPlugin = function (message: string, opts?: { error: Error; errorMetadata: string[] }) {
1313
throw normalizeError('failPlugin', failPlugin, message, opts)
1414
}
1515

1616
// Cancel build. Same as `failBuild` except it marks the build as "canceled".
17-
export const cancelBuild = function (message, opts) {
17+
export const cancelBuild = function (message: string, opts?: { error: Error; errorMetadata: string[] }) {
1818
throw normalizeError('cancelBuild', cancelBuild, message, opts)
1919
}
2020

2121
// `onSuccess`, `onEnd` and `onError` cannot cancel the build since they are run
2222
// or might be run after deployment. When calling `failBuild()` or
2323
// `cancelBuild()`, `failPlugin()` is run instead and a warning is printed.
24-
export const failPluginWithWarning = function (methodName, event, message, opts) {
24+
export const failPluginWithWarning = function (
25+
methodName: string,
26+
event: string,
27+
message: string,
28+
opts: { error: Error; errorMetadata: string[] },
29+
) {
2530
logFailPluginWarning(methodName, event)
2631
failPlugin(message, opts)
2732
}
2833

2934
// An `error` option can be passed to keep the original error message and
3035
// stack trace. An additional `message` string is always required.
31-
const normalizeError = function (type, func, message, { error, errorMetadata } = {}) {
36+
const normalizeError = function (
37+
type: string,
38+
func: (...args: unknown[]) => unknown,
39+
message: string,
40+
{ error, errorMetadata }: { error?: Error; errorMetadata?: string[] } = {},
41+
) {
3242
const errorA = getError(error, message, func)
3343
addErrorInfo(errorA, { type, errorMetadata })
3444
return errorA
3545
}
3646

37-
const getError = function (error, message, func) {
47+
const getError = function (error: unknown, message: string, func: (...args: unknown[]) => unknown) {
3848
if (error instanceof Error) {
3949
// This might fail if `name` is a getter or is non-writable.
4050
try {

0 commit comments

Comments
 (0)