From b72de2e8bd9ac453cdc14a47c9ddd7b852215840 Mon Sep 17 00:00:00 2001
From: Jon Edvald <edvald@gmail.com>
Date: Fri, 13 Aug 2021 18:26:40 +0200
Subject: [PATCH] fix(core): performance issue with certain dependency
 structures

This change reduces the number of times module configs are resolved,
which especially helps for deeply nested dependency graphs. Should
dramatically reduce CPU usage in certain cases (although most users
won't feel much of a difference).
---
 core/src/actions.ts                           |  26 ++-
 core/src/commands/call.ts                     |   9 +-
 core/src/commands/exec.ts                     |   1 +
 core/src/commands/get/get-status.ts           |   7 +-
 core/src/commands/get/get-task-result.ts      |   1 +
 core/src/commands/get/get-test-result.ts      |   1 +
 core/src/commands/logs.ts                     |   2 +-
 core/src/commands/run/module.ts               |   1 +
 core/src/commands/run/service.ts              |   1 +
 core/src/commands/run/test.ts                 |   1 +
 core/src/config-graph.ts                      |   2 +-
 core/src/plugins/kubernetes/run.ts            |   2 +-
 core/src/plugins/kubernetes/system.ts         |   2 +
 core/src/proxy.ts                             |  41 +++--
 core/src/task-graph.ts                        |   2 +-
 core/src/tasks/build.ts                       |   3 +-
 core/src/tasks/delete-service.ts              |   2 +-
 core/src/tasks/deploy.ts                      |  10 +-
 core/src/tasks/get-service-status.ts          |   2 +
 core/src/tasks/get-task-result.ts             |  11 +-
 core/src/tasks/hot-reload.ts                  |  12 +-
 core/src/tasks/publish.ts                     |   2 +-
 core/src/tasks/task.ts                        |   2 +
 core/src/tasks/test.ts                        |   2 +
 core/src/types/plugin/plugin.ts               |   6 +-
 .../plugins/kubernetes/container/container.ts |   1 +
 .../src/plugins/kubernetes/container/run.ts   |   3 +
 .../integ/src/plugins/kubernetes/helm/run.ts  |   3 +
 .../integ/src/plugins/kubernetes/helm/test.ts |   1 +
 .../kubernetes/kubernetes-module/run.ts       |   3 +
 .../kubernetes/kubernetes-module/test.ts      |   1 +
 core/test/integ/src/plugins/kubernetes/run.ts |   2 +
 .../plugins/kubernetes/volume/configmap.ts    |   3 +-
 .../volume/persistentvolumeclaim.ts           |   3 +-
 core/test/unit/src/actions.ts                 | 159 +++++++++++++-----
 core/test/unit/src/plugins/exec.ts            |  21 ++-
 .../unit/src/plugins/terraform/terraform.ts   |   8 +-
 37 files changed, 262 insertions(+), 97 deletions(-)

diff --git a/core/src/actions.ts b/core/src/actions.ts
index b391d3c1c2..a59317c0c3 100644
--- a/core/src/actions.ts
+++ b/core/src/actions.ts
@@ -717,12 +717,13 @@ export class ActionRouter implements TypeGuard {
 
   async getServiceStatuses({
     log,
+    graph,
     serviceNames,
   }: {
     log: LogEntry
+    graph: ConfigGraph
     serviceNames?: string[]
   }): Promise<ServiceStatusMap> {
-    const graph = await this.garden.getConfigGraph(log)
     const services = graph.getServices({ names: serviceNames })
 
     const tasks = services.map(
@@ -942,7 +943,7 @@ export class ActionRouter implements TypeGuard {
     actionType: T
     defaultHandler?: ModuleActionHandlers[T]
   }): Promise<ModuleActionOutputs[T]> {
-    const { module, pluginName, log } = params
+    const { module, pluginName, log, graph } = params
 
     log.silly(`Getting '${actionType}' handler for module '${module.name}' (type '${module.type}')`)
 
@@ -954,7 +955,6 @@ export class ActionRouter implements TypeGuard {
     })
 
     const providers = await this.garden.resolveProviders(log)
-    const graph = await this.garden.getConfigGraph(log)
     const templateContext = ModuleConfigContext.fromModule({
       garden: this.garden,
       resolvedProviders: providers,
@@ -983,7 +983,7 @@ export class ActionRouter implements TypeGuard {
     actionType: T
     defaultHandler?: ServiceActionHandlers[T]
   }) {
-    let { log, service, runtimeContext } = params
+    let { log, service, runtimeContext, graph } = params
     let module = service.module
 
     log.silly(`Getting ${actionType} handler for service ${service.name}`)
@@ -996,7 +996,6 @@ export class ActionRouter implements TypeGuard {
     })
 
     const providers = await this.garden.resolveProviders(log)
-    const graph = await this.garden.getConfigGraph(log, runtimeContext)
 
     const modules = graph.getModules()
     const templateContext = ModuleConfigContext.fromModule({
@@ -1016,6 +1015,9 @@ export class ActionRouter implements TypeGuard {
     if (!runtimeContextIsEmpty && getRuntimeTemplateReferences(module).length > 0) {
       log.silly(`Resolving runtime template strings for service '${service.name}'`)
 
+      // Resolve the graph again (TODO: avoid this somehow!)
+      graph = await this.garden.getConfigGraph(log, runtimeContext)
+
       // Resolve the service again
       service = graph.getService(service.name)
       module = service.module
@@ -1049,7 +1051,7 @@ export class ActionRouter implements TypeGuard {
     actionType: T
     defaultHandler?: TaskActionHandlers[T]
   }) {
-    let { task, log } = params
+    let { task, log, graph } = params
     const runtimeContext = params["runtimeContext"] as RuntimeContext | undefined
     let module = task.module
 
@@ -1063,7 +1065,6 @@ export class ActionRouter implements TypeGuard {
     })
 
     const providers = await this.garden.resolveProviders(log)
-    const graph = await this.garden.getConfigGraph(log, runtimeContext)
 
     const modules = graph.getModules()
     const templateContext = ModuleConfigContext.fromModule({
@@ -1083,6 +1084,9 @@ export class ActionRouter implements TypeGuard {
     if (!runtimeContextIsEmpty && getRuntimeTemplateReferences(module).length > 0) {
       log.silly(`Resolving runtime template strings for task '${task.name}'`)
 
+      // Resolve the graph again (TODO: avoid this somehow!)
+      graph = await this.garden.getConfigGraph(log, runtimeContext)
+
       // Resolve the task again
       task = graph.getTask(task.name)
       module = task.module
@@ -1441,14 +1445,18 @@ type WrappedModuleActionMap = {
 // avoid having to specify common params on each action helper call
 type ActionRouterParams<T extends PluginActionParamsBase> = Omit<T, CommonParams> & { pluginName?: string }
 
-type ModuleActionRouterParams<T extends PluginModuleActionParamsBase> = Omit<T, CommonParams> & { pluginName?: string }
-// additionally make runtimeContext param optional
+type ModuleActionRouterParams<T extends PluginModuleActionParamsBase> = Omit<T, CommonParams> & {
+  graph: ConfigGraph
+  pluginName?: string
+}
 
 type ServiceActionRouterParams<T extends PluginServiceActionParamsBase> = Omit<T, "module" | CommonParams> & {
+  graph: ConfigGraph
   pluginName?: string
 }
 
 type TaskActionRouterParams<T extends PluginTaskActionParamsBase> = Omit<T, "module" | CommonParams> & {
+  graph: ConfigGraph
   pluginName?: string
 }
 
diff --git a/core/src/commands/call.ts b/core/src/commands/call.ts
index 87336dde0e..b0db99cfaf 100644
--- a/core/src/commands/call.ts
+++ b/core/src/commands/call.ts
@@ -75,7 +75,14 @@ export class CallCommand extends Command<Args> {
     // No need for full context, since we're just checking if the service is running.
     const runtimeContext = emptyRuntimeContext
     const actions = await garden.getActionRouter()
-    const status = await actions.getServiceStatus({ service, log, devMode: false, hotReload: false, runtimeContext })
+    const status = await actions.getServiceStatus({
+      service,
+      log,
+      graph,
+      devMode: false,
+      hotReload: false,
+      runtimeContext,
+    })
 
     if (!includes(["ready", "outdated"], status.state)) {
       throw new RuntimeError(`Service ${service.name} is not running`, {
diff --git a/core/src/commands/exec.ts b/core/src/commands/exec.ts
index 5766c8a0db..8dc1cad75b 100644
--- a/core/src/commands/exec.ts
+++ b/core/src/commands/exec.ts
@@ -83,6 +83,7 @@ export class ExecCommand extends Command<Args> {
     const actions = await garden.getActionRouter()
     const result = await actions.execInService({
       log,
+      graph,
       service,
       command,
       interactive: opts.interactive,
diff --git a/core/src/commands/get/get-status.ts b/core/src/commands/get/get-status.ts
index 296baf3765..e475b51130 100644
--- a/core/src/commands/get/get-status.ts
+++ b/core/src/commands/get/get-status.ts
@@ -68,9 +68,10 @@ export class GetStatusCommand extends Command {
 
   async action({ garden, log, opts }: CommandParams): Promise<CommandResult<StatusCommandResult>> {
     const actions = await garden.getActionRouter()
+    const graph = await garden.getConfigGraph(log)
 
     const envStatus = await garden.getEnvironmentStatus(log)
-    const serviceStatuses = await actions.getServiceStatuses({ log })
+    const serviceStatuses = await actions.getServiceStatuses({ log, graph })
 
     let result: StatusCommandResult = {
       providers: envStatus,
@@ -80,7 +81,6 @@ export class GetStatusCommand extends Command {
     }
 
     if (opts.output) {
-      const graph = await garden.getConfigGraph(log)
       result = {
         ...result,
         ...(await Bluebird.props({
@@ -125,6 +125,7 @@ async function getTestStatuses(garden: Garden, configGraph: ConfigGraph, log: Lo
           const result = await actions.getTestResult({
             module,
             log,
+            graph: configGraph,
             test: testFromConfig(module, testConfig, configGraph),
           })
           return [`${module.name}.${testConfig.name}`, runStatus(result)]
@@ -140,7 +141,7 @@ async function getTaskStatuses(garden: Garden, configGraph: ConfigGraph, log: Lo
 
   return fromPairs(
     await Bluebird.map(tasks, async (task) => {
-      const result = await actions.getTaskResult({ task, log })
+      const result = await actions.getTaskResult({ task, log, graph: configGraph })
       return [task.name, runStatus(result)]
     })
   )
diff --git a/core/src/commands/get/get-task-result.ts b/core/src/commands/get/get-task-result.ts
index e1d6132f94..58d842b5cf 100644
--- a/core/src/commands/get/get-task-result.ts
+++ b/core/src/commands/get/get-task-result.ts
@@ -63,6 +63,7 @@ export class GetTaskResultCommand extends Command<Args> {
     const taskResult = await actions.getTaskResult({
       log,
       task,
+      graph,
     })
 
     let result: GetTaskResultCommandResult = null
diff --git a/core/src/commands/get/get-test-result.ts b/core/src/commands/get/get-test-result.ts
index 2088bd3aa2..e6615f1575 100644
--- a/core/src/commands/get/get-test-result.ts
+++ b/core/src/commands/get/get-test-result.ts
@@ -73,6 +73,7 @@ export class GetTestResultCommand extends Command<Args> {
 
     const testResult = await actions.getTestResult({
       log,
+      graph,
       test,
       module,
     })
diff --git a/core/src/commands/logs.ts b/core/src/commands/logs.ts
index 0e42c27da9..309c6626f8 100644
--- a/core/src/commands/logs.ts
+++ b/core/src/commands/logs.ts
@@ -223,7 +223,7 @@ export class LogsCommand extends Command<Args, Opts> {
     const actions = await garden.getActionRouter()
 
     await Bluebird.map(services, async (service: GardenService<any>) => {
-      await actions.getServiceLogs({ log, service, stream, follow, tail, since })
+      await actions.getServiceLogs({ log, graph, service, stream, follow, tail, since })
     })
 
     const sorted = sortBy(result, "timestamp")
diff --git a/core/src/commands/run/module.ts b/core/src/commands/run/module.ts
index 3a466ea68e..2f38fd3185 100644
--- a/core/src/commands/run/module.ts
+++ b/core/src/commands/run/module.ts
@@ -127,6 +127,7 @@ export class RunModuleCommand extends Command<Args, Opts> {
 
     const result = await actions.runModule({
       log,
+      graph,
       module,
       command: opts.command?.split(" "),
       args: args.arguments || [],
diff --git a/core/src/commands/run/service.ts b/core/src/commands/run/service.ts
index ad7f224fcc..6722426180 100644
--- a/core/src/commands/run/service.ts
+++ b/core/src/commands/run/service.ts
@@ -122,6 +122,7 @@ export class RunServiceCommand extends Command<Args, Opts> {
 
     const result = await actions.runService({
       log,
+      graph,
       service,
       runtimeContext,
       interactive,
diff --git a/core/src/commands/run/test.ts b/core/src/commands/run/test.ts
index bf34b6f2fa..de6b675e6b 100644
--- a/core/src/commands/run/test.ts
+++ b/core/src/commands/run/test.ts
@@ -166,6 +166,7 @@ export class RunTestCommand extends Command<Args, Opts> {
 
     const result = await actions.testModule({
       log,
+      graph,
       module,
       silent: false,
       interactive,
diff --git a/core/src/config-graph.ts b/core/src/config-graph.ts
index 5c8469307d..bed67e2d61 100644
--- a/core/src/config-graph.ts
+++ b/core/src/config-graph.ts
@@ -75,7 +75,7 @@ export type DependencyGraph = { [key: string]: DependencyGraphNode }
  * A graph data structure that facilitates querying (recursive or non-recursive) of the project's dependency and
  * dependant relationships.
  *
- * This should be initialized with fully resolved and validated ModuleConfigs.
+ * This should be initialized with resolved and validated GardenModules.
  */
 export class ConfigGraph {
   private dependencyGraph: DependencyGraph
diff --git a/core/src/plugins/kubernetes/run.ts b/core/src/plugins/kubernetes/run.ts
index 14a6756b99..c45b447c4e 100644
--- a/core/src/plugins/kubernetes/run.ts
+++ b/core/src/plugins/kubernetes/run.ts
@@ -857,7 +857,7 @@ export class PodRunner extends PodRunnerParams {
           })
         }
 
-        await sleep(200)
+        await sleep(800)
       }
 
       // Retrieve logs after run
diff --git a/core/src/plugins/kubernetes/system.ts b/core/src/plugins/kubernetes/system.ts
index 2978526195..cb5efe0f19 100644
--- a/core/src/plugins/kubernetes/system.ts
+++ b/core/src/plugins/kubernetes/system.ts
@@ -95,9 +95,11 @@ interface GetSystemServicesStatusParams {
 
 export async function getSystemServiceStatus({ sysGarden, log, serviceNames }: GetSystemServicesStatusParams) {
   const actions = await sysGarden.getActionRouter()
+  const graph = await sysGarden.getConfigGraph(log)
 
   const serviceStatuses = await actions.getServiceStatuses({
     log: log.placeholder({ level: LogLevel.verbose, childEntriesInheritLevel: true }),
+    graph,
     serviceNames,
   })
   const state = combineStates(Object.values(serviceStatuses).map((s) => (s && s.state) || "unknown"))
diff --git a/core/src/proxy.ts b/core/src/proxy.ts
index 19748d780f..05aa4e1e0a 100644
--- a/core/src/proxy.ts
+++ b/core/src/proxy.ts
@@ -18,6 +18,7 @@ import { registerCleanupFunction, sleep } from "./util/util"
 import { LogEntry } from "./logger/log-entry"
 import { GetPortForwardResult } from "./types/plugin/service/getPortForward"
 import { LocalAddress } from "./db/entities/local-address"
+import { ConfigGraph } from "./config-graph"
 
 interface PortProxy {
   key: string
@@ -42,39 +43,55 @@ registerCleanupFunction("kill-service-port-proxies", () => {
 
 const portLock = new AsyncLock()
 
-export async function startPortProxies(garden: Garden, log: LogEntry, service: GardenService, status: ServiceStatus) {
+// tslint:disable-next-line: max-line-length
+export async function startPortProxies({
+  garden,
+  graph,
+  log,
+  service,
+  status,
+}: {
+  garden: Garden
+  graph: ConfigGraph
+  log: LogEntry
+  service: GardenService
+  status: ServiceStatus
+}) {
   if (garden.disablePortForwards) {
     log.info({ msg: chalk.gray("Port forwards disabled") })
     return []
   }
 
   return Bluebird.map(status.forwardablePorts || [], (spec) => {
-    return startPortProxy(garden, log, service, spec)
+    return startPortProxy({ garden, graph, log, service, spec })
   })
 }
 
-async function startPortProxy(garden: Garden, log: LogEntry, service: GardenService, spec: ForwardablePort) {
+interface StartPortProxyParams {
+  garden: Garden
+  graph: ConfigGraph
+  log: LogEntry
+  service: GardenService
+  spec: ForwardablePort
+}
+
+async function startPortProxy({ garden, graph, log, service, spec }: StartPortProxyParams) {
   const key = getPortKey(service, spec)
   let proxy = activeProxies[key]
 
   if (!proxy) {
     // Start new proxy
-    proxy = activeProxies[key] = await createProxy(garden, log, service, spec)
+    proxy = activeProxies[key] = await createProxy({ garden, graph, log, service, spec })
   } else if (!isEqual(proxy.spec, spec)) {
     // Stop existing proxy and create new one
     stopPortProxy(proxy, log)
-    proxy = activeProxies[key] = await createProxy(garden, log, service, spec)
+    proxy = activeProxies[key] = await createProxy({ garden, graph, log, service, spec })
   }
 
   return proxy
 }
 
-async function createProxy(
-  garden: Garden,
-  log: LogEntry,
-  service: GardenService,
-  spec: ForwardablePort
-): Promise<PortProxy> {
+async function createProxy({ garden, graph, log, service, spec }: StartPortProxyParams): Promise<PortProxy> {
   const actions = await garden.getActionRouter()
   const key = getPortKey(service, spec)
   let fwd: GetPortForwardResult | null = null
@@ -91,7 +108,7 @@ async function createProxy(
       log.debug(`Starting port forward to ${key}`)
 
       try {
-        fwd = await actions.getPortForward({ service, log, ...spec })
+        fwd = await actions.getPortForward({ service, log, graph, ...spec })
       } catch (err) {
         log.error(`Error starting port forward to ${key}: ${err.message}`)
       }
diff --git a/core/src/task-graph.ts b/core/src/task-graph.ts
index b88f033d35..1473ed19db 100644
--- a/core/src/task-graph.ts
+++ b/core/src/task-graph.ts
@@ -126,7 +126,7 @@ export class TaskGraph extends EventEmitter2 {
         throw new TaskGraphError(
           dedent`
             ${failed.length} task(s) failed:
-            ${failed.map(([key, result]) => `- ${key}: ${result?.error?.toString()}`).join("\n")}
+            ${failed.map(([key, result]) => `- ${key}: ${result?.error?.stack || result?.error?.message}`).join("\n")}
           `,
           { results }
         )
diff --git a/core/src/tasks/build.ts b/core/src/tasks/build.ts
index e2b29811f6..6c2232c5da 100644
--- a/core/src/tasks/build.ts
+++ b/core/src/tasks/build.ts
@@ -130,7 +130,7 @@ export class BuildTask extends BaseTask {
         status: "active",
       })
 
-      const status = await actions.getBuildStatus({ log: this.log, module })
+      const status = await actions.getBuildStatus({ log: this.log, graph: this.graph, module })
 
       if (status.ready) {
         log.setSuccess({
@@ -148,6 +148,7 @@ export class BuildTask extends BaseTask {
     let result: BuildResult
     try {
       result = await actions.build({
+        graph: this.graph,
         module,
         log,
       })
diff --git a/core/src/tasks/delete-service.ts b/core/src/tasks/delete-service.ts
index 5145019dbe..73c51a7308 100644
--- a/core/src/tasks/delete-service.ts
+++ b/core/src/tasks/delete-service.ts
@@ -79,7 +79,7 @@ export class DeleteServiceTask extends BaseTask {
     let status: ServiceStatus
 
     try {
-      status = await actions.deleteService({ log: this.log, service: this.service })
+      status = await actions.deleteService({ log: this.log, service: this.service, graph: this.graph })
     } catch (err) {
       this.log.setError()
       throw err
diff --git a/core/src/tasks/deploy.ts b/core/src/tasks/deploy.ts
index e97020930a..39c328912f 100644
--- a/core/src/tasks/deploy.ts
+++ b/core/src/tasks/deploy.ts
@@ -107,6 +107,7 @@ export class DeployTask extends BaseTask {
       const taskResultTasks = await Bluebird.map(deps.run, async (task) => {
         return new GetTaskResultTask({
           garden: this.garden,
+          graph: this.graph,
           log: this.log,
           task,
           force: false,
@@ -207,6 +208,7 @@ export class DeployTask extends BaseTask {
     } else {
       try {
         status = await actions.deployService({
+          graph: this.graph,
           service: this.service,
           runtimeContext,
           log,
@@ -230,7 +232,13 @@ export class DeployTask extends BaseTask {
     }
 
     if (this.garden.persistent) {
-      const proxies = await startPortProxies(this.garden, log, this.service, status)
+      const proxies = await startPortProxies({
+        garden: this.garden,
+        graph: this.graph,
+        log,
+        service: this.service,
+        status,
+      })
 
       for (const proxy of proxies) {
         const targetHost = proxy.spec.targetName || this.service.name
diff --git a/core/src/tasks/get-service-status.ts b/core/src/tasks/get-service-status.ts
index ebd5c35b4f..3de884e4c9 100644
--- a/core/src/tasks/get-service-status.ts
+++ b/core/src/tasks/get-service-status.ts
@@ -73,6 +73,7 @@ export class GetServiceStatusTask extends BaseTask {
     const taskResultTasks = await Bluebird.map(deps.run, async (task) => {
       return new GetTaskResultTask({
         garden: this.garden,
+        graph: this.graph,
         log: this.log,
         task,
         force: false,
@@ -121,6 +122,7 @@ export class GetServiceStatusTask extends BaseTask {
 
     try {
       status = await actions.getServiceStatus({
+        graph: this.graph,
         service: this.service,
         log,
         devMode,
diff --git a/core/src/tasks/get-task-result.ts b/core/src/tasks/get-task-result.ts
index cb0c16ef96..023e517d6f 100644
--- a/core/src/tasks/get-task-result.ts
+++ b/core/src/tasks/get-task-result.ts
@@ -13,10 +13,12 @@ import { Garden } from "../garden"
 import { GardenTask } from "../types/task"
 import { RunTaskResult } from "../types/plugin/task/runTask"
 import { Profile } from "../util/profiling"
+import { ConfigGraph } from "../config-graph"
 
 export interface GetTaskResultTaskParams {
   force: boolean
   garden: Garden
+  graph: ConfigGraph
   log: LogEntry
   task: GardenTask
 }
@@ -26,11 +28,13 @@ export class GetTaskResultTask extends BaseTask {
   type: TaskType = "get-task-result"
   concurrencyLimit = 20
 
+  private graph: ConfigGraph
   private task: GardenTask
 
-  constructor({ force, garden, log, task }: GetTaskResultTaskParams) {
-    super({ garden, log, force, version: task.version })
-    this.task = task
+  constructor(params: GetTaskResultTaskParams) {
+    super({ ...params, version: params.task.version })
+    this.graph = params.graph
+    this.task = params.task
   }
 
   async resolveDependencies() {
@@ -57,6 +61,7 @@ export class GetTaskResultTask extends BaseTask {
     let result: RunTaskResult | null | undefined
     try {
       result = await actions.getTaskResult({
+        graph: this.graph,
         task: this.task,
         log,
       })
diff --git a/core/src/tasks/hot-reload.ts b/core/src/tasks/hot-reload.ts
index 78c385c3ec..e496f36037 100644
--- a/core/src/tasks/hot-reload.ts
+++ b/core/src/tasks/hot-reload.ts
@@ -28,15 +28,15 @@ export class HotReloadTask extends BaseTask {
   type: TaskType = "hot-reload"
   concurrencyLimit = 10
 
-  // private graph: ConfigGraph
+  private graph: ConfigGraph
   // private hotReloadServiceNames: string[]
   private service: GardenService
 
-  constructor({ garden, log, service, force }: Params) {
-    super({ garden, log, force, version: service.version })
-    // this.graph = graph
+  constructor(params: Params) {
+    super({ ...params, version: params.service.version })
+    this.graph = params.graph
     // this.hotReloadServiceNames = hotReloadServiceNames || []
-    this.service = service
+    this.service = params.service
   }
 
   async resolveDependencies() {
@@ -80,7 +80,7 @@ export class HotReloadTask extends BaseTask {
     const actions = await this.garden.getActionRouter()
 
     try {
-      await actions.hotReloadService({ log, service: this.service })
+      await actions.hotReloadService({ log, graph: this.graph, service: this.service })
     } catch (err) {
       log.setError()
       throw err
diff --git a/core/src/tasks/publish.ts b/core/src/tasks/publish.ts
index 2d0b0b0f15..f500f44627 100644
--- a/core/src/tasks/publish.ts
+++ b/core/src/tasks/publish.ts
@@ -114,7 +114,7 @@ export class PublishTask extends BaseTask {
 
     let result: PublishModuleResult
     try {
-      result = await actions.publishModule({ module, log, tag })
+      result = await actions.publishModule({ module, log, graph: this.graph, tag })
     } catch (err) {
       log.setError()
       throw err
diff --git a/core/src/tasks/task.ts b/core/src/tasks/task.ts
index 4405aac27b..e83c0e624d 100644
--- a/core/src/tasks/task.ts
+++ b/core/src/tasks/task.ts
@@ -108,6 +108,7 @@ export class TaskTask extends BaseTask {
     const resultTask = new GetTaskResultTask({
       force: this.force,
       garden: this.garden,
+      graph: this.graph,
       log: this.log,
       task: this.task,
     })
@@ -166,6 +167,7 @@ export class TaskTask extends BaseTask {
     let result: RunTaskResult
     try {
       result = await actions.runTask({
+        graph: this.graph,
         task,
         log,
         runtimeContext,
diff --git a/core/src/tasks/test.ts b/core/src/tasks/test.ts
index c026da973a..080000a280 100644
--- a/core/src/tasks/test.ts
+++ b/core/src/tasks/test.ts
@@ -184,6 +184,7 @@ export class TestTask extends BaseTask {
         log,
         interactive: false,
         module: this.test.module,
+        graph: this.graph,
         runtimeContext,
         silent: true,
         test: this.test,
@@ -217,6 +218,7 @@ export class TestTask extends BaseTask {
 
     return actions.getTestResult({
       log: this.log,
+      graph: this.graph,
       module: this.test.module,
       test: this.test,
     })
diff --git a/core/src/types/plugin/plugin.ts b/core/src/types/plugin/plugin.ts
index 05c6aa7c12..540efd5979 100644
--- a/core/src/types/plugin/plugin.ts
+++ b/core/src/types/plugin/plugin.ts
@@ -30,7 +30,7 @@ import { RunServiceParams, runService } from "./service/runService"
 import { RunTaskParams, RunTaskResult, runTask } from "./task/runTask"
 import { SetSecretParams, SetSecretResult, setSecret } from "./provider/setSecret"
 import { TestModuleParams, testModule } from "./module/testModule"
-import { joiArray, joiIdentifier, joi, joiSchema } from "../../config/common"
+import { joiArray, joiIdentifier, joi, joiSchema, CustomObjectSchema } from "../../config/common"
 import { GardenModule } from "../module"
 import { RunResult } from "./base"
 import { ServiceStatus } from "../service"
@@ -111,8 +111,8 @@ export type ModuleActionName = keyof ModuleActionParams
 export interface PluginActionDescription {
   description: string
   // TODO: specify the schemas using primitives and not Joi objects
-  paramsSchema: Joi.ObjectSchema
-  resultSchema: Joi.ObjectSchema
+  paramsSchema: CustomObjectSchema
+  resultSchema: CustomObjectSchema
 }
 
 export interface PluginActionParams {
diff --git a/core/test/integ/src/plugins/kubernetes/container/container.ts b/core/test/integ/src/plugins/kubernetes/container/container.ts
index c5f1bc867b..2f81967892 100644
--- a/core/test/integ/src/plugins/kubernetes/container/container.ts
+++ b/core/test/integ/src/plugins/kubernetes/container/container.ts
@@ -232,6 +232,7 @@ describe("kubernetes container module handlers", () => {
       // We also verify that, despite the test failing, its result was still saved.
       const result = await actions.getTestResult({
         log: garden.log,
+        graph,
         module,
         test,
       })
diff --git a/core/test/integ/src/plugins/kubernetes/container/run.ts b/core/test/integ/src/plugins/kubernetes/container/run.ts
index 9fc02376d2..6f071e5817 100644
--- a/core/test/integ/src/plugins/kubernetes/container/run.ts
+++ b/core/test/integ/src/plugins/kubernetes/container/run.ts
@@ -68,6 +68,7 @@ describe("runContainerTask", () => {
     const storedResult = await actions.getTaskResult({
       log: garden.log,
       task,
+      graph,
     })
 
     expect(storedResult).to.exist
@@ -98,6 +99,7 @@ describe("runContainerTask", () => {
     const storedResult = await actions.getTaskResult({
       log: garden.log,
       task,
+      graph,
     })
 
     expect(storedResult).to.not.exist
@@ -131,6 +133,7 @@ describe("runContainerTask", () => {
     const result = await actions.getTaskResult({
       log: garden.log,
       task,
+      graph,
     })
 
     expect(result).to.exist
diff --git a/core/test/integ/src/plugins/kubernetes/helm/run.ts b/core/test/integ/src/plugins/kubernetes/helm/run.ts
index 1382a63f10..5476fbad02 100644
--- a/core/test/integ/src/plugins/kubernetes/helm/run.ts
+++ b/core/test/integ/src/plugins/kubernetes/helm/run.ts
@@ -63,6 +63,7 @@ describe("runHelmTask", () => {
     const storedResult = await actions.getTaskResult({
       log: garden.log,
       task,
+      graph,
     })
 
     expect(storedResult).to.exist
@@ -95,6 +96,7 @@ describe("runHelmTask", () => {
     const storedResult = await actions.getTaskResult({
       log: garden.log,
       task,
+      graph,
     })
 
     expect(storedResult).to.not.exist
@@ -150,6 +152,7 @@ describe("runHelmTask", () => {
     const result = await actions.getTaskResult({
       log: garden.log,
       task,
+      graph,
     })
 
     expect(result).to.exist
diff --git a/core/test/integ/src/plugins/kubernetes/helm/test.ts b/core/test/integ/src/plugins/kubernetes/helm/test.ts
index c6254e7109..9e7318b953 100644
--- a/core/test/integ/src/plugins/kubernetes/helm/test.ts
+++ b/core/test/integ/src/plugins/kubernetes/helm/test.ts
@@ -108,6 +108,7 @@ describe("testHelmModule", () => {
       log: garden.log,
       module,
       test,
+      graph,
     })
 
     expect(result).to.exist
diff --git a/core/test/integ/src/plugins/kubernetes/kubernetes-module/run.ts b/core/test/integ/src/plugins/kubernetes/kubernetes-module/run.ts
index f1e0ed47ed..5576b88891 100644
--- a/core/test/integ/src/plugins/kubernetes/kubernetes-module/run.ts
+++ b/core/test/integ/src/plugins/kubernetes/kubernetes-module/run.ts
@@ -62,6 +62,7 @@ describe("runKubernetesTask", () => {
     const storedResult = await actions.getTaskResult({
       log: garden.log,
       task,
+      graph,
     })
 
     expect(storedResult).to.exist
@@ -94,6 +95,7 @@ describe("runKubernetesTask", () => {
     const storedResult = await actions.getTaskResult({
       log: garden.log,
       task,
+      graph,
     })
 
     expect(storedResult).to.not.exist
@@ -149,6 +151,7 @@ describe("runKubernetesTask", () => {
     const result = await actions.getTaskResult({
       log: garden.log,
       task,
+      graph,
     })
 
     expect(result).to.exist
diff --git a/core/test/integ/src/plugins/kubernetes/kubernetes-module/test.ts b/core/test/integ/src/plugins/kubernetes/kubernetes-module/test.ts
index 1a83de5a53..3286ee0763 100644
--- a/core/test/integ/src/plugins/kubernetes/kubernetes-module/test.ts
+++ b/core/test/integ/src/plugins/kubernetes/kubernetes-module/test.ts
@@ -108,6 +108,7 @@ describe("testKubernetesModule", () => {
       log: garden.log,
       module,
       test,
+      graph,
     })
 
     expect(result).to.exist
diff --git a/core/test/integ/src/plugins/kubernetes/run.ts b/core/test/integ/src/plugins/kubernetes/run.ts
index 92e2c4016e..bd1b9074a4 100644
--- a/core/test/integ/src/plugins/kubernetes/run.ts
+++ b/core/test/integ/src/plugins/kubernetes/run.ts
@@ -1215,6 +1215,7 @@ describe("kubernetes Pod runner functions", () => {
         await actions.build({
           module,
           log: garden.log,
+          graph,
         })
 
         await expectError(
@@ -1256,6 +1257,7 @@ describe("kubernetes Pod runner functions", () => {
         await actions.build({
           module,
           log: garden.log,
+          graph,
         })
 
         await expectError(
diff --git a/core/test/integ/src/plugins/kubernetes/volume/configmap.ts b/core/test/integ/src/plugins/kubernetes/volume/configmap.ts
index 7dd5f7fe80..c957c432ce 100644
--- a/core/test/integ/src/plugins/kubernetes/volume/configmap.ts
+++ b/core/test/integ/src/plugins/kubernetes/volume/configmap.ts
@@ -90,6 +90,7 @@ describe("configmap module", () => {
     const status = await actions.getServiceStatus({
       log: garden.log,
       service,
+      graph,
       devMode: false,
       hotReload: false,
       runtimeContext: emptyRuntimeContext,
@@ -108,6 +109,6 @@ describe("configmap module", () => {
       })
     ).to.be.true
 
-    await actions.deleteService({ log: garden.log, service })
+    await actions.deleteService({ log: garden.log, service, graph })
   })
 })
diff --git a/core/test/integ/src/plugins/kubernetes/volume/persistentvolumeclaim.ts b/core/test/integ/src/plugins/kubernetes/volume/persistentvolumeclaim.ts
index 9fc8446a1d..0ca38a36cd 100644
--- a/core/test/integ/src/plugins/kubernetes/volume/persistentvolumeclaim.ts
+++ b/core/test/integ/src/plugins/kubernetes/volume/persistentvolumeclaim.ts
@@ -97,6 +97,7 @@ describe("persistentvolumeclaim", () => {
       service,
       devMode: false,
       hotReload: false,
+      graph,
       runtimeContext: emptyRuntimeContext,
     })
 
@@ -113,6 +114,6 @@ describe("persistentvolumeclaim", () => {
       })
     ).to.be.true
 
-    await actions.deleteService({ log: garden.log, service })
+    await actions.deleteService({ log: garden.log, service, graph })
   })
 })
diff --git a/core/test/unit/src/actions.ts b/core/test/unit/src/actions.ts
index c5b773bb08..f17e1ddd22 100644
--- a/core/test/unit/src/actions.ts
+++ b/core/test/unit/src/actions.ts
@@ -26,7 +26,7 @@ import Stream from "ts-stream"
 import { GardenTask } from "../../../src/types/task"
 import { expect } from "chai"
 import { omit } from "lodash"
-import { joi } from "../../../src/config/common"
+import { CustomObjectSchema, joi } from "../../../src/config/common"
 import { validateSchema } from "../../../src/config/validation"
 import { ProjectConfig, defaultNamespace } from "../../../src/config/project"
 import { DEFAULT_API_VERSION } from "../../../src/constants"
@@ -269,7 +269,7 @@ describe("ActionRouter", () => {
 
     describe("getBuildStatus", () => {
       it("should correctly call the corresponding plugin handler", async () => {
-        const result = await actions.getBuildStatus({ log, module })
+        const result = await actions.getBuildStatus({ log, module, graph })
         expect(result).to.eql({
           ready: true,
         })
@@ -277,7 +277,7 @@ describe("ActionRouter", () => {
 
       it("should emit a buildStatus event", async () => {
         garden.events.eventLog = []
-        await actions.getBuildStatus({ log, module })
+        await actions.getBuildStatus({ log, module, graph })
         const event = garden.events.eventLog[0]
         expect(event).to.exist
         expect(event.name).to.eql("buildStatus")
@@ -290,13 +290,13 @@ describe("ActionRouter", () => {
 
     describe("build", () => {
       it("should correctly call the corresponding plugin handler", async () => {
-        const result = await actions.build({ log, module })
+        const result = await actions.build({ log, module, graph })
         expect(result).to.eql({})
       })
 
       it("should emit buildStatus events", async () => {
         garden.events.eventLog = []
-        await actions.build({ log, module })
+        await actions.build({ log, module, graph })
         const event1 = garden.events.eventLog[0]
         const event2 = garden.events.eventLog[1]
         const moduleVersion = module.version.versionString
@@ -320,6 +320,7 @@ describe("ActionRouter", () => {
         const result = await actions.hotReloadService({
           log,
           service,
+          graph,
           runtimeContext: {
             envVars: { FOO: "bar" },
             dependencies: [],
@@ -337,6 +338,7 @@ describe("ActionRouter", () => {
           module,
           args: command,
           interactive: true,
+          graph,
           runtimeContext: {
             envVars: { FOO: "bar" },
             dependencies: [],
@@ -371,6 +373,7 @@ describe("ActionRouter", () => {
           log,
           module,
           interactive: true,
+          graph,
           runtimeContext: {
             envVars: { FOO: "bar" },
             dependencies: [],
@@ -410,6 +413,7 @@ describe("ActionRouter", () => {
           log,
           module,
           interactive: true,
+          graph,
           runtimeContext: {
             envVars: { FOO: "bar" },
             dependencies: [],
@@ -466,6 +470,7 @@ describe("ActionRouter", () => {
           log,
           module,
           interactive: true,
+          graph,
           runtimeContext: {
             envVars: { FOO: "bar" },
             dependencies: [],
@@ -500,6 +505,7 @@ describe("ActionRouter", () => {
           log,
           module,
           test,
+          graph,
         })
         expect(result).to.eql({
           moduleName: module.name,
@@ -524,6 +530,7 @@ describe("ActionRouter", () => {
         log,
         module,
         test,
+        graph,
       })
       const event = garden.events.eventLog[0]
       expect(event).to.exist
@@ -543,6 +550,7 @@ describe("ActionRouter", () => {
         const result = await actions.getServiceStatus({
           log,
           service,
+          graph,
           runtimeContext,
           devMode: false,
           hotReload: false,
@@ -552,7 +560,7 @@ describe("ActionRouter", () => {
 
       it("should emit a serviceStatus event", async () => {
         garden.events.eventLog = []
-        await actions.getServiceStatus({ log, service, runtimeContext, devMode: false, hotReload: false })
+        await actions.getServiceStatus({ log, service, graph, runtimeContext, devMode: false, hotReload: false })
         const event = garden.events.eventLog[0]
         expect(event).to.exist
         expect(event.name).to.eql("serviceStatus")
@@ -569,7 +577,7 @@ describe("ActionRouter", () => {
         })
 
         await expectError(
-          () => actions.getServiceStatus({ log, service, runtimeContext, devMode: false, hotReload: false }),
+          () => actions.getServiceStatus({ log, service, graph, runtimeContext, devMode: false, hotReload: false }),
           (err) =>
             expect(stripAnsi(err.message)).to.equal(
               "Error validating outputs from service 'service-a': key .foo must be a string"
@@ -583,7 +591,7 @@ describe("ActionRouter", () => {
         })
 
         await expectError(
-          () => actions.getServiceStatus({ log, service, runtimeContext, devMode: false, hotReload: false }),
+          () => actions.getServiceStatus({ log, service, graph, runtimeContext, devMode: false, hotReload: false }),
           (err) =>
             expect(stripAnsi(err.message)).to.equal(
               "Error validating outputs from service 'service-a': key .base must be a string"
@@ -597,6 +605,7 @@ describe("ActionRouter", () => {
         const result = await actions.deployService({
           log,
           service,
+          graph,
           runtimeContext,
           force: true,
           devMode: false,
@@ -607,7 +616,15 @@ describe("ActionRouter", () => {
 
       it("should emit serviceStatus events", async () => {
         garden.events.eventLog = []
-        await actions.deployService({ log, service, runtimeContext, force: true, devMode: false, hotReload: false })
+        await actions.deployService({
+          log,
+          service,
+          graph,
+          runtimeContext,
+          force: true,
+          devMode: false,
+          hotReload: false,
+        })
         const moduleVersion = service.module.version.versionString
         const event1 = garden.events.eventLog[0]
         const event2 = garden.events.eventLog[1]
@@ -635,7 +652,16 @@ describe("ActionRouter", () => {
         })
 
         await expectError(
-          () => actions.deployService({ log, service, runtimeContext, force: true, devMode: false, hotReload: false }),
+          () =>
+            actions.deployService({
+              log,
+              service,
+              graph,
+              runtimeContext,
+              force: true,
+              devMode: false,
+              hotReload: false,
+            }),
           (err) =>
             expect(stripAnsi(err.message)).to.equal(
               "Error validating outputs from service 'service-a': key .foo must be a string"
@@ -649,7 +675,16 @@ describe("ActionRouter", () => {
         })
 
         await expectError(
-          () => actions.deployService({ log, service, runtimeContext, force: true, devMode: false, hotReload: false }),
+          () =>
+            actions.deployService({
+              log,
+              service,
+              graph,
+              runtimeContext,
+              force: true,
+              devMode: false,
+              hotReload: false,
+            }),
           (err) =>
             expect(stripAnsi(err.message)).to.equal(
               "Error validating outputs from service 'service-a': key .base must be a string"
@@ -660,7 +695,7 @@ describe("ActionRouter", () => {
 
     describe("deleteService", () => {
       it("should correctly call the corresponding plugin handler", async () => {
-        const result = await actions.deleteService({ log, service, runtimeContext })
+        const result = await actions.deleteService({ log, service, graph, runtimeContext })
         expect(result).to.eql({ forwardablePorts: [], state: "ready", detail: {}, outputs: {} })
       })
     })
@@ -670,6 +705,7 @@ describe("ActionRouter", () => {
         const result = await actions.execInService({
           log,
           service,
+          graph,
           runtimeContext,
           command: ["foo"],
           interactive: false,
@@ -681,7 +717,15 @@ describe("ActionRouter", () => {
     describe("getServiceLogs", () => {
       it("should correctly call the corresponding plugin handler", async () => {
         const stream = new Stream<ServiceLogEntry>()
-        const result = await actions.getServiceLogs({ log, service, runtimeContext, stream, follow: false, tail: -1 })
+        const result = await actions.getServiceLogs({
+          log,
+          service,
+          graph,
+          runtimeContext,
+          stream,
+          follow: false,
+          tail: -1,
+        })
         expect(result).to.eql({})
       })
     })
@@ -692,6 +736,7 @@ describe("ActionRouter", () => {
           log,
           service,
           interactive: true,
+          graph,
           runtimeContext: {
             envVars: { FOO: "bar" },
             dependencies: [],
@@ -735,6 +780,7 @@ describe("ActionRouter", () => {
         const result = await actions.getTaskResult({
           log,
           task,
+          graph,
         })
         expect(result).to.eql(taskResult)
       })
@@ -744,6 +790,7 @@ describe("ActionRouter", () => {
         await actions.getTaskResult({
           log,
           task,
+          graph,
         })
         const event = garden.events.eventLog[0]
         expect(event).to.exist
@@ -762,7 +809,7 @@ describe("ActionRouter", () => {
         })
 
         await expectError(
-          () => actions.getTaskResult({ log, task }),
+          () => actions.getTaskResult({ log, task, graph }),
           (err) =>
             expect(stripAnsi(err.message)).to.equal(
               "Error validating outputs from task 'task-a': key .foo must be a string"
@@ -776,7 +823,7 @@ describe("ActionRouter", () => {
         })
 
         await expectError(
-          () => actions.getTaskResult({ log, task }),
+          () => actions.getTaskResult({ log, task, graph }),
           (err) =>
             expect(stripAnsi(err.message)).to.equal(
               "Error validating outputs from task 'task-a': key .base must be a string"
@@ -791,6 +838,7 @@ describe("ActionRouter", () => {
           log,
           task,
           interactive: true,
+          graph,
           runtimeContext: {
             envVars: { FOO: "bar" },
             dependencies: [],
@@ -805,6 +853,7 @@ describe("ActionRouter", () => {
           log,
           task,
           interactive: true,
+          graph,
           runtimeContext: {
             envVars: { FOO: "bar" },
             dependencies: [],
@@ -842,6 +891,7 @@ describe("ActionRouter", () => {
               log,
               task,
               interactive: true,
+              graph,
               runtimeContext: {
                 envVars: { FOO: "bar" },
                 dependencies: [],
@@ -865,6 +915,7 @@ describe("ActionRouter", () => {
               log,
               task,
               interactive: true,
+              graph,
               runtimeContext: {
                 envVars: { FOO: "bar" },
                 dependencies: [],
@@ -897,6 +948,7 @@ describe("ActionRouter", () => {
           log,
           task: _task,
           interactive: true,
+          graph,
           runtimeContext: {
             envVars: { FOO: "bar" },
             dependencies: [],
@@ -1587,6 +1639,7 @@ describe("ActionRouter", () => {
         params: {
           module: moduleA,
           log,
+          graph,
         },
         defaultHandler: handler,
       })
@@ -1615,6 +1668,7 @@ describe("ActionRouter", () => {
         params: {
           module: moduleB,
           log,
+          graph,
         },
         defaultHandler: handler,
       })
@@ -1656,6 +1710,7 @@ describe("ActionRouter", () => {
         actionType: "deployService", // Doesn't matter which one it is
         params: {
           service: serviceA,
+          graph,
           runtimeContext,
           log,
           devMode: false,
@@ -1705,6 +1760,7 @@ describe("ActionRouter", () => {
         actionType: "deployService", // Doesn't matter which one it is
         params: {
           service: serviceB,
+          graph,
           runtimeContext: _runtimeContext,
           log,
           devMode: false,
@@ -1757,6 +1813,7 @@ describe("ActionRouter", () => {
         actionType: "deployService", // Doesn't matter which one it is
         params: {
           service: serviceA,
+          graph,
           runtimeContext: _runtimeContext,
           log,
           devMode: false,
@@ -1806,6 +1863,7 @@ describe("ActionRouter", () => {
             actionType: "deployService", // Doesn't matter which one it is
             params: {
               service: serviceA,
+              graph,
               runtimeContext: _runtimeContext,
               log,
               devMode: false,
@@ -1874,6 +1932,7 @@ describe("ActionRouter", () => {
         params: {
           artifactsPath: "/tmp",
           task: taskA,
+          graph,
           runtimeContext,
           log,
           interactive: false,
@@ -1921,6 +1980,7 @@ describe("ActionRouter", () => {
         params: {
           artifactsPath: "/tmp", // Not used in this test
           task: taskA,
+          graph,
           runtimeContext: _runtimeContext,
           log,
           interactive: false,
@@ -1987,6 +2047,7 @@ describe("ActionRouter", () => {
         params: {
           artifactsPath: "/tmp", // Not used in this test
           task: taskA,
+          graph,
           runtimeContext: _runtimeContext,
           log,
           interactive: false,
@@ -2047,6 +2108,7 @@ describe("ActionRouter", () => {
             params: {
               artifactsPath: "/tmp", // Not used in this test
               task: taskA,
+              graph,
               runtimeContext: _runtimeContext,
               log,
               interactive: false,
@@ -2090,12 +2152,12 @@ const testPlugin = createGardenPlugin({
 
   handlers: <PluginActionHandlers>{
     configureProvider: async (params) => {
-      validateSchema(params, pluginActionDescriptions.configureProvider.paramsSchema)
+      validateParams(params, pluginActionDescriptions.configureProvider.paramsSchema)
       return { config: params.config }
     },
 
     getEnvironmentStatus: async (params) => {
-      validateSchema(params, pluginActionDescriptions.getEnvironmentStatus.paramsSchema)
+      validateParams(params, pluginActionDescriptions.getEnvironmentStatus.paramsSchema)
       return {
         ready: false,
         outputs: {},
@@ -2103,7 +2165,7 @@ const testPlugin = createGardenPlugin({
     },
 
     augmentGraph: async (params) => {
-      validateSchema(params, pluginActionDescriptions.augmentGraph.paramsSchema)
+      validateParams(params, pluginActionDescriptions.augmentGraph.paramsSchema)
 
       const moduleName = "added-by-" + params.ctx.provider.name
 
@@ -2127,37 +2189,37 @@ const testPlugin = createGardenPlugin({
     },
 
     getDashboardPage: async (params) => {
-      validateSchema(params, pluginActionDescriptions.getDashboardPage.paramsSchema)
+      validateParams(params, pluginActionDescriptions.getDashboardPage.paramsSchema)
       return { url: "http://" + params.page.name }
     },
 
     getDebugInfo: async (params) => {
-      validateSchema(params, pluginActionDescriptions.getDebugInfo.paramsSchema)
+      validateParams(params, pluginActionDescriptions.getDebugInfo.paramsSchema)
       return { info: {} }
     },
 
     prepareEnvironment: async (params) => {
-      validateSchema(params, pluginActionDescriptions.prepareEnvironment.paramsSchema)
+      validateParams(params, pluginActionDescriptions.prepareEnvironment.paramsSchema)
       return { status: { ready: true, outputs: {} } }
     },
 
     cleanupEnvironment: async (params) => {
-      validateSchema(params, pluginActionDescriptions.cleanupEnvironment.paramsSchema)
+      validateParams(params, pluginActionDescriptions.cleanupEnvironment.paramsSchema)
       return {}
     },
 
     getSecret: async (params) => {
-      validateSchema(params, pluginActionDescriptions.getSecret.paramsSchema)
+      validateParams(params, pluginActionDescriptions.getSecret.paramsSchema)
       return { value: params.key }
     },
 
     setSecret: async (params) => {
-      validateSchema(params, pluginActionDescriptions.setSecret.paramsSchema)
+      validateParams(params, pluginActionDescriptions.setSecret.paramsSchema)
       return {}
     },
 
     deleteSecret: async (params) => {
-      validateSchema(params, pluginActionDescriptions.deleteSecret.paramsSchema)
+      validateParams(params, pluginActionDescriptions.deleteSecret.paramsSchema)
       return { found: true }
     },
   },
@@ -2176,7 +2238,7 @@ const testPlugin = createGardenPlugin({
 
       handlers: <ModuleAndRuntimeActionHandlers>{
         configure: async (params) => {
-          validateSchema(params, moduleActionDescriptions.configure.paramsSchema)
+          validateParams(params, moduleActionDescriptions.configure.paramsSchema)
 
           const serviceConfigs = params.moduleConfig.spec.services.map((spec) => ({
             name: spec.name,
@@ -2211,7 +2273,7 @@ const testPlugin = createGardenPlugin({
         },
 
         getModuleOutputs: async (params) => {
-          validateSchema(params, moduleActionDescriptions.getModuleOutputs.paramsSchema)
+          validateParams(params, moduleActionDescriptions.getModuleOutputs.paramsSchema)
           return { outputs: { foo: "bar" } }
         },
 
@@ -2220,27 +2282,27 @@ const testPlugin = createGardenPlugin({
         },
 
         getBuildStatus: async (params) => {
-          validateSchema(params, moduleActionDescriptions.getBuildStatus.paramsSchema)
+          validateParams(params, moduleActionDescriptions.getBuildStatus.paramsSchema)
           return { ready: true }
         },
 
         build: async (params) => {
-          validateSchema(params, moduleActionDescriptions.build.paramsSchema)
+          validateParams(params, moduleActionDescriptions.build.paramsSchema)
           return {}
         },
 
         publish: async (params) => {
-          validateSchema(params, moduleActionDescriptions.publish.paramsSchema)
+          validateParams(params, moduleActionDescriptions.publish.paramsSchema)
           return { published: true }
         },
 
         hotReloadService: async (params) => {
-          validateSchema(params, moduleActionDescriptions.hotReloadService.paramsSchema)
+          validateParams(params, moduleActionDescriptions.hotReloadService.paramsSchema)
           return {}
         },
 
         runModule: async (params) => {
-          validateSchema(params, moduleActionDescriptions.runModule.paramsSchema)
+          validateParams(params, moduleActionDescriptions.runModule.paramsSchema)
           return {
             moduleName: params.module.name,
             command: params.args,
@@ -2253,7 +2315,7 @@ const testPlugin = createGardenPlugin({
         },
 
         testModule: async (params) => {
-          validateSchema(params, moduleActionDescriptions.testModule.paramsSchema)
+          validateParams(params, moduleActionDescriptions.testModule.paramsSchema)
 
           // Create artifacts, to test artifact copying
           for (const artifact of params.test.config.spec.artifacts || []) {
@@ -2276,7 +2338,7 @@ const testPlugin = createGardenPlugin({
         },
 
         getTestResult: async (params) => {
-          validateSchema(params, moduleActionDescriptions.getTestResult.paramsSchema)
+          validateParams(params, moduleActionDescriptions.getTestResult.paramsSchema)
           return {
             moduleName: params.module.name,
             command: [],
@@ -2293,22 +2355,22 @@ const testPlugin = createGardenPlugin({
         },
 
         getServiceStatus: async (params) => {
-          validateSchema(params, moduleActionDescriptions.getServiceStatus.paramsSchema)
+          validateParams(params, moduleActionDescriptions.getServiceStatus.paramsSchema)
           return { state: "ready", detail: {}, outputs: { base: "ok", foo: "ok" } }
         },
 
         deployService: async (params) => {
-          validateSchema(params, moduleActionDescriptions.deployService.paramsSchema)
+          validateParams(params, moduleActionDescriptions.deployService.paramsSchema)
           return { state: "ready", detail: {}, outputs: { base: "ok", foo: "ok" } }
         },
 
         deleteService: async (params) => {
-          validateSchema(params, moduleActionDescriptions.deleteService.paramsSchema)
+          validateParams(params, moduleActionDescriptions.deleteService.paramsSchema)
           return { state: "ready", detail: {} }
         },
 
         execInService: async (params) => {
-          validateSchema(params, moduleActionDescriptions.execInService.paramsSchema)
+          validateParams(params, moduleActionDescriptions.execInService.paramsSchema)
           return {
             code: 0,
             output: "bla bla",
@@ -2316,12 +2378,12 @@ const testPlugin = createGardenPlugin({
         },
 
         getServiceLogs: async (params) => {
-          validateSchema(params, moduleActionDescriptions.getServiceLogs.paramsSchema)
+          validateParams(params, moduleActionDescriptions.getServiceLogs.paramsSchema)
           return {}
         },
 
         runService: async (params) => {
-          validateSchema(params, moduleActionDescriptions.runService.paramsSchema)
+          validateParams(params, moduleActionDescriptions.runService.paramsSchema)
           return {
             moduleName: params.module.name,
             command: ["foo"],
@@ -2334,7 +2396,7 @@ const testPlugin = createGardenPlugin({
         },
 
         getPortForward: async (params) => {
-          validateSchema(params, moduleActionDescriptions.getPortForward.paramsSchema)
+          validateParams(params, moduleActionDescriptions.getPortForward.paramsSchema)
           return {
             hostname: "bla",
             port: 123,
@@ -2342,12 +2404,12 @@ const testPlugin = createGardenPlugin({
         },
 
         stopPortForward: async (params) => {
-          validateSchema(params, moduleActionDescriptions.stopPortForward.paramsSchema)
+          validateParams(params, moduleActionDescriptions.stopPortForward.paramsSchema)
           return {}
         },
 
         getTaskResult: async (params) => {
-          validateSchema(params, moduleActionDescriptions.getTaskResult.paramsSchema)
+          validateParams(params, moduleActionDescriptions.getTaskResult.paramsSchema)
           const module = params.task.module
           return {
             moduleName: module.name,
@@ -2363,7 +2425,7 @@ const testPlugin = createGardenPlugin({
         },
 
         runTask: async (params) => {
-          validateSchema(params, moduleActionDescriptions.runTask.paramsSchema)
+          validateParams(params, moduleActionDescriptions.runTask.paramsSchema)
 
           const module = params.task.module
 
@@ -2393,3 +2455,12 @@ const testPluginB = createGardenPlugin({
   ...omit(testPlugin, ["createModuleTypes"]),
   name: "test-plugin-b",
 })
+
+function validateParams(params: any, schema: CustomObjectSchema) {
+  validateSchema(
+    params,
+    schema.keys({
+      graph: joi.object(),
+    })
+  )
+}
diff --git a/core/test/unit/src/plugins/exec.ts b/core/test/unit/src/plugins/exec.ts
index 265d58c278..fff2db20fb 100644
--- a/core/test/unit/src/plugins/exec.ts
+++ b/core/test/unit/src/plugins/exec.ts
@@ -395,7 +395,7 @@ describe("exec plugin", () => {
 
       await garden.buildStaging.syncFromSrc(module, log)
       const actions = await garden.getActionRouter()
-      await actions.build({ log, module })
+      await actions.build({ log, module, graph })
 
       const versionFileContents = await readModuleVersionFile(versionFilePath)
 
@@ -405,7 +405,7 @@ describe("exec plugin", () => {
     it("should run the build command in the module dir if local true", async () => {
       const module = graph.getModule("module-local")
       const actions = await garden.getActionRouter()
-      const res = await actions.build({ log, module })
+      const res = await actions.build({ log, module, graph })
       expect(res.buildLog).to.eql(join(projectRoot, "module-local"))
     })
 
@@ -414,7 +414,7 @@ describe("exec plugin", () => {
       const actions = await garden.getActionRouter()
 
       module.spec.build.command = ["echo", "$GARDEN_MODULE_VERSION"]
-      const res = await actions.build({ log, module })
+      const res = await actions.build({ log, module, graph })
 
       expect(res.buildLog).to.equal(module.version.versionString)
     })
@@ -428,6 +428,7 @@ describe("exec plugin", () => {
         log,
         module,
         interactive: true,
+        graph,
         runtimeContext: {
           envVars: {},
           dependencies: [],
@@ -457,6 +458,7 @@ describe("exec plugin", () => {
         log,
         module,
         interactive: true,
+        graph,
         runtimeContext: {
           envVars: {},
           dependencies: [],
@@ -488,6 +490,7 @@ describe("exec plugin", () => {
         log,
         task,
         interactive: true,
+        graph,
         runtimeContext: {
           envVars: {},
           dependencies: [],
@@ -507,6 +510,7 @@ describe("exec plugin", () => {
         log,
         task,
         interactive: true,
+        graph,
         runtimeContext: {
           envVars: {},
           dependencies: [],
@@ -527,6 +531,7 @@ describe("exec plugin", () => {
         command: [],
         args: ["echo", "hello", "world"],
         interactive: false,
+        graph,
         runtimeContext: {
           envVars: {},
           dependencies: [],
@@ -553,6 +558,7 @@ describe("exec plugin", () => {
           hotReload: false,
           log,
           service,
+          graph,
           runtimeContext: {
             envVars: {},
             dependencies: [],
@@ -572,6 +578,7 @@ describe("exec plugin", () => {
               hotReload: false,
               log,
               service,
+              graph,
               runtimeContext: {
                 envVars: {},
                 dependencies: [],
@@ -598,6 +605,7 @@ describe("exec plugin", () => {
           hotReload: false,
           log,
           service,
+          graph,
           runtimeContext: {
             envVars: {},
             dependencies: [],
@@ -615,6 +623,7 @@ describe("exec plugin", () => {
           force: false,
           log,
           service,
+          graph,
           runtimeContext: {
             envVars: {},
             dependencies: [],
@@ -625,6 +634,7 @@ describe("exec plugin", () => {
           hotReload: false,
           log,
           service,
+          graph,
           runtimeContext: {
             envVars: {},
             dependencies: [],
@@ -642,6 +652,7 @@ describe("exec plugin", () => {
           hotReload: false,
           log,
           service,
+          graph,
           runtimeContext: {
             envVars: {},
             dependencies: [],
@@ -661,6 +672,7 @@ describe("exec plugin", () => {
           force: false,
           log,
           service,
+          graph,
           runtimeContext: {
             envVars: {},
             dependencies: [],
@@ -669,6 +681,7 @@ describe("exec plugin", () => {
         const res = await actions.deleteService({
           log,
           service,
+          graph,
           runtimeContext: {
             envVars: {},
             dependencies: [],
@@ -684,6 +697,7 @@ describe("exec plugin", () => {
         const res = await actions.deleteService({
           log,
           service,
+          graph,
           runtimeContext: {
             envVars: {},
             dependencies: [],
@@ -700,6 +714,7 @@ describe("exec plugin", () => {
             await actions.deleteService({
               log,
               service,
+              graph,
               runtimeContext: {
                 envVars: {},
                 dependencies: [],
diff --git a/core/test/unit/src/plugins/terraform/terraform.ts b/core/test/unit/src/plugins/terraform/terraform.ts
index 802366fb71..ac426a861e 100644
--- a/core/test/unit/src/plugins/terraform/terraform.ts
+++ b/core/test/unit/src/plugins/terraform/terraform.ts
@@ -498,6 +498,7 @@ describe("Terraform module type", () => {
         devMode: false,
         hotReload: false,
         log: garden.log,
+        graph,
         runtimeContext: emptyRuntimeContext,
       })
 
@@ -533,6 +534,7 @@ describe("Terraform module type", () => {
         devMode: false,
         hotReload: false,
         log: _garden.log,
+        graph,
         runtimeContext: emptyRuntimeContext,
       })
 
@@ -590,7 +592,7 @@ describe("Terraform module type", () => {
       const actions = await garden.getActionRouter()
       const service = graph.getService("tf")
 
-      await actions.deleteService({ service, log: garden.log })
+      await actions.deleteService({ service, log: garden.log, graph })
 
       const testFileContent = await readFile(testFilePath)
       expect(testFileContent.toString()).to.equal("default")
@@ -604,7 +606,7 @@ describe("Terraform module type", () => {
       const actions = await garden.getActionRouter()
       const service = graph.getService("tf")
 
-      await actions.deleteService({ service, log: garden.log })
+      await actions.deleteService({ service, log: garden.log, graph })
 
       expect(await pathExists(testFilePath)).to.be.false
     })
@@ -626,7 +628,7 @@ describe("Terraform module type", () => {
 
       await setWorkspace({ ctx, provider, root: tfRoot, log: _garden.log, workspace: "default" })
 
-      await actions.deleteService({ service, log: _garden.log })
+      await actions.deleteService({ service, log: _garden.log, graph: _graph })
 
       const { selected } = await getWorkspaces({ ctx, provider, root: tfRoot, log: _garden.log })
       expect(selected).to.equal("foo")