Skip to content

Commit

Permalink
feat(exec): add script option to exec provider configuration
Browse files Browse the repository at this point in the history
This allows projects to define an arbitrary script to run on init.

This is handy for running arbitrary script when initializing.
For example, another provider might declare a dependency on this
provider, to ensure this script runs before resolving that provider.
  • Loading branch information
edvald committed Jun 26, 2020
1 parent bd87271 commit 35a175b
Show file tree
Hide file tree
Showing 9 changed files with 225 additions and 35 deletions.
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
* [`conftest-container`](./reference/providers/conftest-container.md)
* [`conftest-kubernetes`](./reference/providers/conftest-kubernetes.md)
* [`conftest`](./reference/providers/conftest.md)
* [`exec`](./reference/providers/exec.md)
* [`hadolint`](./reference/providers/hadolint.md)
* [`kubernetes`](./reference/providers/kubernetes.md)
* [`local-kubernetes`](./reference/providers/local-kubernetes.md)
Expand Down
1 change: 1 addition & 0 deletions docs/reference/providers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ title: Providers
* [`conftest-container`](./conftest-container.md)
* [`kubernetes`](./kubernetes.md)
* [`conftest-kubernetes`](./conftest-kubernetes.md)
* [`exec`](./exec.md)
* [`hadolint`](./hadolint.md)
* [`local-kubernetes`](./local-kubernetes.md)
* [`maven-container`](./maven-container.md)
Expand Down
94 changes: 94 additions & 0 deletions docs/reference/providers/exec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
---
title: "`exec` Provider"
tocTitle: "`exec`"
---

# `exec` Provider

## Description

A simple provider that allows running arbitary scripts when initializing providers, and provides the exec
module type.

_Note: This provider is always loaded when running Garden. You only need to explicitly declare it in your provider
configuration if you want to configure a script for it to run._

Below is the full schema reference for the provider configuration. For an introduction to configuring a Garden project with providers, please look at our [configuration guide](../../guides/configuration-files.md).

The reference is divided into two sections. The [first section](#complete-yaml-schema) contains the complete YAML schema, and the [second section](#configuration-keys) describes each schema key.

## Complete YAML Schema

The values in the schema below are the default values.

```yaml
providers:
- # The name of the provider plugin to use.
name:

# If specified, this provider will only be used in the listed environments. Note that an empty array effectively
# disables the provider. To use a provider in all environments, omit this field.
environments:

# An optional script to run in the project root when initializing providers. This is handy for running an
# arbitrary
# script when initializing. For example, another provider might declare a dependency on this provider, to ensure
# this script runs before resolving that provider.
initScript:
```
## Configuration Keys
### `providers[]`

| Type | Default | Required |
| --------------- | ------- | -------- |
| `array[object]` | `[]` | No |

### `providers[].name`

[providers](#providers) > name

The name of the provider plugin to use.

| Type | Required |
| -------- | -------- |
| `string` | Yes |

Example:

```yaml
providers:
- name: "local-kubernetes"
```

### `providers[].environments[]`

[providers](#providers) > environments

If specified, this provider will only be used in the listed environments. Note that an empty array effectively disables the provider. To use a provider in all environments, omit this field.

| Type | Required |
| --------------- | -------- |
| `array[string]` | No |

Example:

```yaml
providers:
- environments:
- dev
- stage
```

### `providers[].initScript`

[providers](#providers) > initScript

An optional script to run in the project root when initializing providers. This is handy for running an arbitrary
script when initializing. For example, another provider might declare a dependency on this provider, to ensure
this script runs before resolving that provider.

| Type | Required |
| -------- | -------- |
| `string` | No |

34 changes: 4 additions & 30 deletions garden-service/src/commands/run/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ import { ConfigurationError, FilesystemError } from "../../exceptions"
import { posix, join } from "path"
import { ensureDir, writeFile } from "fs-extra"
import Bluebird from "bluebird"
import { splitStream, getDurationMsec } from "../../util/util"
import execa, { ExecaError } from "execa"
import { getDurationMsec } from "../../util/util"
import { runScript } from "../../util/util"
import { ExecaError } from "execa"
import { LogLevel } from "../../logger/log-node"

const runWorkflowArgs = {
Expand Down Expand Up @@ -358,35 +359,8 @@ class WorkflowScriptError extends GardenBaseError {
}

export async function runStepScript({ garden, log, step }: RunStepParams): Promise<CommandResult<any>> {
// Run the script, capturing any errors
const proc = execa("bash", ["-s"], {
all: true,
cwd: garden.projectRoot,
// The script is piped to stdin
input: step.script,
// Set a very large max buffer (we only hold one of these at a time, and want to avoid overflow errors)
buffer: true,
maxBuffer: 100 * 1024 * 1024,
})

// Stream output to `log`, splitting by line
const stdout = splitStream()
const stderr = splitStream()

stdout.on("error", () => {})
stdout.on("data", (line: Buffer) => {
log.info(line.toString())
})
stderr.on("error", () => {})
stderr.on("data", (line: Buffer) => {
log.info(line.toString())
})

proc.stdout!.pipe(stdout)
proc.stderr!.pipe(stderr)

try {
await proc
await runScript(log, garden.projectRoot, step.script!)
return { result: {} }
} catch (_err) {
const error = _err as ExecaError
Expand Down
3 changes: 2 additions & 1 deletion garden-service/src/docs/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export async function writeConfigReferenceDocs(docsRoot: string) {
{ name: "conftest" },
{ name: "conftest-container" },
{ name: "conftest-kubernetes" },
{ name: "exec" },
{ name: "hadolint" },
{ name: "kubernetes" },
{ name: "local-kubernetes" },
Expand All @@ -77,7 +78,7 @@ export async function writeConfigReferenceDocs(docsRoot: string) {
const name = plugin.name

// Currently nothing to document for these
if (name === "container" || name === "exec") {
if (name === "container") {
continue
}

Expand Down
48 changes: 46 additions & 2 deletions garden-service/src/plugins/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ import { BuildModuleParams, BuildResult } from "../types/plugin/module/build"
import { TestModuleParams } from "../types/plugin/module/testModule"
import { TestResult } from "../types/plugin/module/getTestResult"
import { RunTaskParams, RunTaskResult } from "../types/plugin/task/runTask"
import { exec } from "../util/util"
import { ConfigurationError } from "../exceptions"
import { exec, runScript } from "../util/util"
import { ConfigurationError, RuntimeError } from "../exceptions"
import { LogEntry } from "../logger/log-entry"
import { providerConfigBaseSchema } from "../config/provider"
import { ExecaError } from "execa"

const execPathDoc = dedent`
By default, the command is run inside the Garden build directory (under .garden/build/<module-name>).
Expand Down Expand Up @@ -321,6 +323,20 @@ export async function runExecTask(params: RunTaskParams<ExecModule>): Promise<Ru

export const execPlugin = createGardenPlugin({
name: "exec",
docs: dedent`
A simple provider that allows running arbitary scripts when initializing providers, and provides the exec
module type.
_Note: This provider is always loaded when running Garden. You only need to explicitly declare it in your provider
configuration if you want to configure a script for it to run._
`,
configSchema: providerConfigBaseSchema().keys({
initScript: joi.string().description(dedent`
An optional script to run in the project root when initializing providers. This is handy for running an arbitrary
script when initializing. For example, another provider might declare a dependency on this provider, to ensure
this script runs before resolving that provider.
`),
}),
createModuleTypes: [
{
name: "exec",
Expand Down Expand Up @@ -356,6 +372,34 @@ export const execPlugin = createGardenPlugin({
},
},
],
handlers: {
async getEnvironmentStatus({ ctx }) {
// Return ready if there is no initScript to run
return { ready: !ctx.provider.config.initScript, outputs: {} }
},
async prepareEnvironment({ ctx, log }) {
if (ctx.provider.config.initScript) {
try {
log.info({ section: "exec", msg: "Running init script" })
await runScript(log, ctx.projectRoot, ctx.provider.config.initScript)
} catch (_err) {
const error = _err as ExecaError

// Unexpected error (failed to execute script, as opposed to script returning an error code)
if (!error.exitCode) {
throw error
}

throw new RuntimeError(`exec provider init script exited with code ${error.exitCode}`, {
exitCode: error.exitCode,
stdout: error.stdout,
stderr: error.stderr,
})
}
}
return { status: { ready: true, outputs: {} } }
},
},
})

export const gardenPlugin = execPlugin
Expand Down
31 changes: 31 additions & 0 deletions garden-service/src/util/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,3 +603,34 @@ export const splitStream = require("split2")
export function getDurationMsec(start: Date, end: Date): number {
return Math.round(end.getTime() - start.getTime())
}

export async function runScript(log: LogEntry, cwd: string, script: string) {
// Run the script, capturing any errors
const proc = execa("bash", ["-s"], {
all: true,
cwd,
// The script is piped to stdin
input: script,
// Set a very large max buffer (we only hold one of these at a time, and want to avoid overflow errors)
buffer: true,
maxBuffer: 100 * 1024 * 1024,
})

// Stream output to `log`, splitting by line
const stdout = splitStream()
const stderr = splitStream()

stdout.on("error", () => {})
stdout.on("data", (line: Buffer) => {
log.info(line.toString())
})
stderr.on("error", () => {})
stderr.on("data", (line: Buffer) => {
log.info(line.toString())
})

proc.stdout!.pipe(stdout)
proc.stderr!.pipe(stderr)

await proc
}
2 changes: 1 addition & 1 deletion garden-service/test/unit/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -803,7 +803,7 @@ describe("ActionRouter", () => {
it("should return all handlers for a type", async () => {
const handlers = await actions["getActionHandlers"]("prepareEnvironment")

expect(Object.keys(handlers)).to.eql(["test-plugin", "test-plugin-b"])
expect(Object.keys(handlers)).to.eql(["exec", "test-plugin", "test-plugin-b"])
})
})

Expand Down
46 changes: 45 additions & 1 deletion garden-service/test/unit/src/plugins/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { expect } from "chai"
import { join, resolve } from "path"
import { Garden } from "../../../../src/garden"
import { gardenPlugin, configureExecModule } from "../../../../src/plugins/exec"
import { GARDEN_BUILD_VERSION_FILENAME } from "../../../../src/constants"
import { GARDEN_BUILD_VERSION_FILENAME, DEFAULT_API_VERSION } from "../../../../src/constants"
import { LogEntry } from "../../../../src/logger/log-entry"
import { keyBy } from "lodash"
import { getDataDir, makeTestModule, expectError } from "../../../helpers"
Expand All @@ -22,6 +22,8 @@ import { ConfigGraph } from "../../../../src/config-graph"
import { pathExists, emptyDir } from "fs-extra"
import { TestTask } from "../../../../src/tasks/test"
import { findByName } from "../../../../src/util/util"
import { defaultNamespace } from "../../../../src/config/project"
import { readFile } from "fs-extra"

describe("exec plugin", () => {
const projectRoot = resolve(dataDir, "test-project-exec")
Expand All @@ -38,6 +40,48 @@ describe("exec plugin", () => {
await garden.clearBuilds()
})

it("should run a script on init in the project root, if configured", async () => {
const _garden = await Garden.factory(garden.projectRoot, {
plugins: [],
config: {
apiVersion: DEFAULT_API_VERSION,
kind: "Project",
name: "test",
path: garden.projectRoot,
defaultEnvironment: "default",
dotIgnoreFiles: [],
environments: [{ name: "default", defaultNamespace, variables: {} }],
providers: [{ name: "exec", initScript: "echo hello! > .garden/test.txt" }],
variables: {},
},
})

await _garden.resolveProviders(_garden.log)

const f = await readFile(join(garden.projectRoot, ".garden", "test.txt"))

expect(f.toString().trim()).to.equal("hello!")
})

it("should throw if a script configured and exits with a non-zero code", async () => {
const _garden = await Garden.factory(garden.projectRoot, {
plugins: [],
config: {
apiVersion: DEFAULT_API_VERSION,
kind: "Project",
name: "test",
path: garden.projectRoot,
defaultEnvironment: "default",
dotIgnoreFiles: [],
environments: [{ name: "default", defaultNamespace, variables: {} }],
providers: [{ name: "exec", initScript: "echo oh no!; exit 1" }],
variables: {},
},
})

await expectError(() => _garden.resolveProviders(_garden.log), "plugin")
})

it("should correctly parse exec modules", async () => {
const modules = keyBy(graph.getModules(), "name")
const { "module-a": moduleA, "module-b": moduleB, "module-c": moduleC, "module-local": moduleLocal } = modules
Expand Down

0 comments on commit 35a175b

Please sign in to comment.