Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: initial support for Deploy Configuration API #5509

Merged
merged 24 commits into from
Feb 14, 2024

Conversation

eduardoboucas
Copy link
Member

Summary

Adds an MVP for the Deploy Configuration API, with the view to unblock ADN-117.

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
@eduardoboucas eduardoboucas requested review from a team as code owners February 9, 2024 14:36
Copy link
Contributor

github-actions bot commented Feb 9, 2024

This pull request adds or modifies JavaScript (.js, .cjs, .mjs) files.
Consider converting them to TypeScript.

coreStepName: 'Applying Deploy Configuration',
coreStepDescription: () => '',
condition: () => true,
priority: 1,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a new field that will allow us to specify the order of core steps within the same event. We need it so that we always apply the configuration changes before the build command, and it will also unblock COM-328.

coreStepDescription: () => '',
condition: () => true,
priority: 1,
quiet: true,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a new field that lets us omit log output from this specific core step, since in this case it would only add noise to people's build logs.

return {}
}

const newConfig = merge(netlifyConfig, config)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What rules does this use for things like redirect arrays? Is the order defined?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now it's merging them so that the entries from the deploy configuration come last, which is what we want in redirects, headers, and edge functions. We'll need special handling once/if we want to implement the headers! notation we discussed for defining precedence over user-defined values.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me

Copy link
Contributor

@JGAntunes JGAntunes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good overall, added a bunch of questions and small set of suggestions 👍

return {}
}

const newConfig = merge(netlifyConfig, config)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably something you're already aware but if I recall correctly we have an allow list of fields that we allow to mutate:

  • export const applyMutations = function (inlineConfig, configMutations) {
    return configMutations.reduce(applyMutation, inlineConfig)
    }
    const applyMutation = function (inlineConfig, { keys, value, event }) {
    const propName = getPropName(keys)
    if (!(propName in MUTABLE_PROPS)) {
    throwUserError(`"netlifyConfig.${propName}" is read-only.`)
    }
    const { lastEvent, denormalize } = MUTABLE_PROPS[propName]
    validateEvent(lastEvent, event, propName)
    return denormalize === undefined ? setProp(inlineConfig, keys, value) : denormalize(inlineConfig, value, keys)
    }
    const validateEvent = function (lastEvent, event, propName) {
    if (EVENTS.indexOf(lastEvent) < EVENTS.indexOf(event)) {
    throwUserError(`"netlifyConfig.${propName}" cannot be modified after "${lastEvent}".`)
    }
    }
    // `functions['*'].*` has higher priority than `functions.*` so we convert the
    // latter to the former.
    const denormalizeFunctionsTopProps = function (
    { functions, functions: { [WILDCARD_ALL]: wildcardProps } = {}, ...inlineConfig },
    value,
    [, key],
    ) {
    return FUNCTION_CONFIG_PROPERTIES.has(key)
    ? {
    ...inlineConfig,
    functions: { ...functions, [WILDCARD_ALL]: { ...wildcardProps, [key]: value } },
    }
    : { ...inlineConfig, functions: { ...functions, [key]: value } }
    }
    // List of properties that are not read-only.
    const MUTABLE_PROPS = {
    'build.command': { lastEvent: 'onPreBuild' },
    'build.edge_functions': { lastEvent: 'onPostBuild' },
    'build.environment': { lastEvent: 'onPostBuild' },
    'build.environment.*': { lastEvent: 'onPostBuild' },
    'build.functions': { lastEvent: 'onBuild' },
    'build.processing': { lastEvent: 'onPostBuild' },
    'build.processing.css': { lastEvent: 'onPostBuild' },
    'build.processing.css.bundle': { lastEvent: 'onPostBuild' },
    'build.processing.css.minify': { lastEvent: 'onPostBuild' },
    'build.processing.html': { lastEvent: 'onPostBuild' },
    'build.processing.html.pretty_urls': { lastEvent: 'onPostBuild' },
    'build.processing.images': { lastEvent: 'onPostBuild' },
    'build.processing.images.compress': { lastEvent: 'onPostBuild' },
    'build.processing.js': { lastEvent: 'onPostBuild' },
    'build.processing.js.bundle': { lastEvent: 'onPostBuild' },
    'build.processing.js.minify': { lastEvent: 'onPostBuild' },
    'build.processing.skip_processing': { lastEvent: 'onPostBuild' },
    'build.publish': { lastEvent: 'onPostBuild' },
    'build.services': { lastEvent: 'onPostBuild' },
    'build.services.*': { lastEvent: 'onPostBuild' },
    edge_functions: { lastEvent: 'onPostBuild' },
    'functions.*': { lastEvent: 'onBuild', denormalize: denormalizeFunctionsTopProps },
    'functions.*.*': { lastEvent: 'onBuild' },
    headers: { lastEvent: 'onPostBuild' },
    images: { lastEvent: 'onPostBuild' },
    'images.remote_images': { lastEvent: 'onPostBuild' },
    redirects: { lastEvent: 'onPostBuild' },
    }

Depending on the API we want to expose in the deploy config API we might need to revisit that list (or create some sort of exception for this particular step)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, sort of related, if I'm understanding correctly here we're deep merging the config objects. After which ntl/config will take care of running through it's merge logic - https://github.com/netlify/build/blob/main/packages/config/src/merge.js - which takes care of removing things such as duplicate redirect entries and what not right? 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably something you're already aware but if I recall correctly we have an allow list of fields that we allow to mutate

Interestingly, I'm seeing that I'm able to mutate fields in the deploy configuration API that plugins shouldn't be able to mutate, which is a bit confusing to me since I'm using the exact same mechanism for mutating the config.

I'm working on that. ⏳

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, sort of related, if I'm understanding correctly here we're deep merging the config objects. After which ntl/config will take care of running through it's merge logic - https://github.com/netlify/build/blob/main/packages/config/src/merge.js - which takes care of removing things such as duplicate redirect entries and what not right? 🤔

I think you're right, but I'm adding some tests to assert that.

import type { NetlifyConfig } from '../../index.js'
import { CoreStep, CoreStepFunction } from '../types.js'

const coreStep: CoreStepFunction = async function ({ buildDir, netlifyConfig }) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker but thinking it could be useful to leverage our traces here and add some data on the fields that changed (and potentially the values if these don't hold sensitive info). This could help us with future headaches around debugging config changes or spot potential inconsistencies. We have other core steps already creating some spans we can use as boilerplate:

  • await tracer.startActiveSpan(
    'scanning-files',
    { attributes: { keysToSearchFor, totalFiles: filePaths.length } },
    async (span) => {
    scanResults = await scanFilesForKeyValues({
    env: envVars,
    keys: keysToSearchFor,
    base: buildDir as string,
    filePaths,
    })
    const attributesForLogsAndSpan = {
    secretsScanFoundSecrets: scanResults.matches.length > 0,
    secretsScanMatchesCount: scanResults.matches.length,
    secretsFilesCount: scanResults.scannedFilesCount,
    keysToSearchFor,
    }
    systemLog?.(attributesForLogsAndSpan)
    span.setAttributes(attributesForLogsAndSpan)
    span.end()
    },
    )

Alternatively we could just add some events on the current span (we already create a span per execution step):

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't added traces yet, but I did add a system logger. Will definitely revisit, though.


config = JSON.parse(data) as Partial<NetlifyConfig>
} catch {
return {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sort of related with the above ☝️ but would be cool to add this error to the span (probably makes sense to create a span for this step in this case though)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added some logic to ignore the case where we don't find a file and throw an error otherwise.

@@ -37,6 +37,10 @@ const NORMALIZE_REGEXPS = [
/(^|[ "'(=])((?:\.{0,2}|([A-Z]:)|file:\/\/)(\/[^ "')\n]+))/gm,

(_, prefix, pathMatch, winDrive, pathTrail) => {
if (pathMatch.includes('/$netlify-snapshot-preserve/')) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without this, all paths would get normalised to /external/path and the snapshot would be useless in telling us which redirects take precedence. This acts as an escape hatch for the path normalisation.

Yes, this is weird. But so is the whole normalisation logic we have in snapshot tests, unfortunately.

Copy link
Contributor

@lukasholzer lukasholzer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eduardoboucas could you please expand on why you've chosen a build plugin for the deploy configuration API? instead of being part of @netlify/config and completely decoupled from the plugin executions?

To be honest for me this feels a bit brittle to do all those configuration merges inside netlify build as netlify config would be the better suited place IMHO.
Would love to know the background why you have chosen it to be a build plugin!

@eduardoboucas
Copy link
Member Author

@eduardoboucas could you please expand on why you've chosen a build plugin for the deploy configuration API? instead of being part of @netlify/config and completely decoupled from the plugin executions?

To be honest for me this feels a bit brittle to do all those configuration merges inside netlify build as netlify config would be the better suited place IMHO.

Would love to know the background why you have chosen it to be a build plugin!

The tricky part here is that this configuration needs to be loaded at a very specific point of the build process, which is after the build command runs (since the build command is the one responsible for writing the file).

Netlify Config is loaded at the beginning of the build, and I don't know of a good way to augment it at a specific point of the build without involving the build runner. Hence the core step (not a build plugin, since it lives in our space and can't be controlled by user code).

If you have any ideas, I'm all ears.

JGAntunes
JGAntunes previously approved these changes Feb 13, 2024
Copy link
Contributor

@JGAntunes JGAntunes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understanding the time constraints around this I'm 👍 on my side. A couple of things that I would like to us to revisit:

  • I would like us to have more visibility into this step, ideally via tracing (which properties change, which were skipped, anything errored out, etc.)
  • +1 to what @lukasholzer said and to what you suggested @eduardoboucas around moving this logic to ntl/config and maybe having a separate entrypoint we call from here (thereby scoping the whole logic to ntl/config)

}

// Filtering out any properties that can't be mutated using this API.
config = filterConfig(config, [], ALLOWED_PROPERTIES, systemLog)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the reason for us to do this here too just fail fast vs letting the plugin run and ntl/config trying to run the mutations?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also it would be useful to maybe add some system logging or span event saying we've skipped X properties because we're filtering or something like that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the reason for us to do this here too just fail fast vs letting the plugin run and ntl/config trying to run the mutations?

I think the Deploy Configuration API should have its own set of properties that can be mutated — for example, build.command can be mutated by a plugin, but it doesn't make sense for it to be mutated using the Deploy Configuration API since that is expected to be generated during the build command.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also it would be useful to maybe add some system logging or span event saying we've skipped X properties because we're filtering or something like that.

I've added some system logging for every property we use and discard.


systemLog(`Failed to read Deploy Configuration API: ${err.message}`)

throw new Error('An error occured while processing the platform configurarion defined by your framework.')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocker. Should we consider exposing err.message here as way for folks to potentially self serve?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure they can self-serve a solution if the error occurs on a file that is written by their framework, though. 😕


// Merging the different configuration sources. The order here is important.
// Leftmost elements of the array take precedence.
const newConfig = merge.all([configOverrides, netlifyConfig, config])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use the mergeConfigs function from @netlify/config for that instead of reimplementing this piece here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a50d8d8.

// no-op
},
}) {
const configPath = resolve(buildDir, '.netlify', 'deploy', 'v1', 'config.json')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: easier to search and find for paths if we preserve paths

Suggested change
const configPath = resolve(buildDir, '.netlify', 'deploy', 'v1', 'config.json')
const configPath = resolve(buildDir, '.netlify/deploy/v1/config.json')

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@@ -42,7 +41,7 @@ export const runCoreSteps = async (buildSteps: string[], flags: Partial<BuildFla
}
}

const getBuildSteps = function (buildSteps: CoreStep[]) {
const getBuildSteps = function (buildSteps: string[]) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was incorrectly typed. buildSteps is an array of strings — example: https://github.com/netlify/cli/blob/a0f7247640a3ece1fc55702c4679796ccfcd642f/src/commands/deploy/deploy.ts#L373

Copy link
Contributor

@lukasholzer lukasholzer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGMT!

@eduardoboucas eduardoboucas merged commit 70ef75f into main Feb 14, 2024
36 checks passed
@eduardoboucas eduardoboucas deleted the feat/deploy-config branch February 14, 2024 09:39
This was referenced May 27, 2024
This was referenced Jun 26, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants