Skip to content

Instantly share code, notes, and snippets.

@DavidWells
Last active April 13, 2020 00:26
Show Gist options
  • Save DavidWells/1e05977a9a4aaf184f53f456fac2889c to your computer and use it in GitHub Desktop.
Save DavidWells/1e05977a9a4aaf184f53f456fac2889c to your computer and use it in GitHub Desktop.
Netlify Build Plugin Outputs spec

Build Plugin Outputs Spec

Plugin outputs are a way for values to be passed between plugins.

These outputs will allow for more advances use cases of plugins that depend on values from each other.

For example, a mongodb-atlas-plugin might pass a connection string back as an output that a custom-rest-api-function-plugin might use to automatically scaffold a serverless function with said connection string.

To achieve a feature like this, Netlify Build requires additional configuration from the plugin author so outputs and build order can be correctly executed.

Considerations

Plugin outputs must work in these 2 scenarios:

  1. Plugins defined in netlify config file
  2. Plugins being programmatically invoked ("orchestrator plugins")

Below is a proposal on how these two can work.

1. Plugins defined in netlify config file

Here we use 2 plugins. pluginOne has required inputs and pluginTwo's output values are used in pluginOne.

// Has required config property
module.exports = function pluginOne(pluginConfig) {
  return {
    // Name of plugin
    name: 'plugin-one',
    // Config needed for plugin
    config: {
      biz: {
        type: 'string',
        required: true,
      }
    },
    // Lifecycle functions to run
    onBuild: ({ pluginConfig }) => {
      console.log(pluginConfig.biz)
    },
  }
}
// Exposes 1 output during onPreBuild phase
module.exports = function pluginTwo(pluginConfig) {
  return {
    // Name of plugin
    name: 'plugin-two',
    // Outputs returned for DAG resolution
    outputs: {
      foo: {
        type: 'string',
        // `when` is important for DAG resolution
        when: 'onPreBuild'
      }
    },
    // Lifecycle functions to run
    onPreBuild: (pluginAPI) => {
      // Output value `foo` is returned from onPreBuild phase
      return {
        foo: 'hello'
      }
    },
  }
}
# Netlify Config
build:
  publish: build
  functions: functions

plugins:
  - package: plugin-one
    config:
      biz: ${pluginTwo.outputs.foo}
      # ^ output referenced from `id`
      # given to plugin-two
  - package: plugin-two
    id: pluginTwo

As an aside, the configuration could also be ordered like so:

# Netlify Config
build:
  publish: build
  functions: functions

plugins:
  - package: plugin-two
    id: pluginTwo
  - package: plugin-one
    config:
      biz: ${pluginTwo.outputs.foo}

What happens on build:

Inputs & outputs dependancies are ordered up front via a DAG and cycles throw an error.

  1. Required inputs validated in plugin-one. Output syntax is recognized, the when value is read & lifecycle is checked to verify ordering works.
  2. Order works, so onPreBuild functionality from plugin-two runs
  3. Then output foo from plugin-two is returned. ${pluginTwo.outputs.foo} then fully resolved to hello.
  4. onBuild functionality from plugin-one runs with it's biz config value set to hello
  5. Then build ends

2. Plugins used programmatically

Here we use 3 plugins. The plugin-one & plugin-two are programmatically used in a third plugin called orchestratorPlugin. Because they are programmatically used, the order of lifecycle methods in plugin-one & plugin-two do not matter. Effectively they are used as normal NPM modules.

Programmatic usage from orchestrator plugin. Because these are used programmatically and are not defined in the Netlify config file, they can be called in any order the user wishes.

const pluginOne = require('plugin-one')
const pluginTwo = require('plugin-two')

module.exports = function orchestratorPlugin(config) {
  return {
    // Name of plugin
    name: 'orchestrator-plugin',
    // Config needed for plugin
    config: {
      optOne: {
        type: 'string',
        required: true,
      },
      optTwo: {
        type: 'string',
        required: true,
      }
    },
    // Lifecycle functions to run
    onInit: async ({ pluginConfig }) => {
      // initialize plugins with config
      const one = pluginOne({ biz: pluginConfig.optOne  })
      const two = pluginTwo({ zaz: pluginConfig.optTwo })

      const [outputFromOne, outputFromTwo] = await Promise.all([
        one.onPreBuild(pluginAPI),
        two.onBuild(pluginAPI),
      ])
      // Do custom stuff with outputFromOne / outputFromTwo
    },
  }
}
module.exports = function pluginOne(pluginConfig) {
  return {
    // Name of plugin
    name: 'plugin-one',
    // Config needed for plugin
    config: {
      biz: {
        type: 'string',
        required: true,
      }
    },
    // Outputs returned for DAG resolution
    outputs: {
      wow: {
        type: 'string',
        when: 'onBuild'
      }
    },
    onPreBuild: ({ pluginConfig }) => {
      console.log(pluginConfig.biz)
      return {
        wow: 'nice'
      }
    },
  }
}
// Has required config property
module.exports = function pluginTwo(pluginConfig) {
  return {
    // Name of plugin
    name: 'plugin-two',
    // Config needed for plugin
    config: {
      zaz: {
        type: 'string',
        required: true,
      }
    },
    // outputs returned for DAG resolution
    outputs: {
      wow: {
        type: 'string',
        when: 'onBuild'
      }
    },
    onBuild: ({ pluginConfig }) => {
      console.log(pluginConfig.zaz)
    },
  }
}
build:
  publish: build

plugins:
  - package: orchestrator-plugin
    config:
      optOne: hello
      optTwo: goodbye

What happens on build:

Because they are programmatically used, the order of lifecycle methods in plugin-one & plugin-two do not matter.

  1. Required inputs validated from read config file
  2. orchestrator-plugin loads & onInit functionality runs with config set to optOne: hello & optTwo: goodbye
  3. plugin-one & plugin-two are initialized with config
  4. Then plugin-one.onPreBuild & plugin-two.onBuild methods are called
  5. Then output from plugin-one.onPreBuild & plugin-two.onBuild are referenced in the code of orchestrator-plugin.
  6. Then the build ends

Implementation Proposal

The way values are resolved and lifecycle events are ordered depend on the inputs & outputs of each event listener.

There are probably multiple ways to implement something like this. It would be important to note how mature tools like terraform & cloudformation use DAG as their mechanism for resolving & ordering operations.

I'm proposing we use a DAG algorithm to resolve the order in which plugin lifecycle methods fire.

The DAG will tell us the order in which the build steps should happen. It will also be able to detect cycles and throw an error if plugin outputs don't exist yet where they are being used as plugin config (inputs).

The DAG implementation really only effects plugins that are defined in the Netlify configuration file vs the plugins that are called programmatically (because they are just function calls & resolve themselves in user code)

Proposed Resolution flow:

DAG netlify build

Related issues

Feedback and comments welcome

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment