diff --git a/.circleci/config.yml b/.circleci/config.yml index 353a0aed9b..aff26d354a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -444,7 +444,7 @@ jobs: environment: K8S_VERSION: <> MINIKUBE_VERSION: v1.5.2 - GARDEN_LOG_LEVEL: silly + GARDEN_LOG_LEVEL: debug GARDEN_LOGGER_TYPE: basic steps: - checkout diff --git a/docs/guides/in-cluster-building.md b/docs/guides/in-cluster-building.md index f835d9418d..3a26c561a8 100644 --- a/docs/guides/in-cluster-building.md +++ b/docs/guides/in-cluster-building.md @@ -25,10 +25,8 @@ DigitalOcean (track [issue #877](https://github.com/garden-io/garden/issues/877) Specifically, the clusters need the following: -- Support for `hostPort`, and for reaching `hostPort`s from the node/Kubelet. This should work out-of-the-box in most - standard setups, but clusters using Cilium for networking may need to configure this specifically, for example. -- At least 2GB of RAM _on top of your own service requirements_. More RAM is strongly recommended if you have many - concurrent developers or CI builds. +- Support for `hostPort`, and for reaching `hostPort`s from the node/Kubelet. This should work out-of-the-box in most standard setups, but clusters using Cilium for networking may need to configure this specifically, for example. +- At least 2GB of RAM _on top of your own service requirements_. More RAM is strongly recommended if you have many concurrent developers or CI builds. - Support for `PersistentVolumeClaim`s and enough disk space for layer caches and the in-cluster image registry. You can—_and should_—adjust the allocated resources and storage in the provider configuration, under @@ -69,9 +67,7 @@ In this mode, builds are executed as follows: After enabling this mode (we currently still default to the `local-docker` mode), you will need to run `garden plugins kubernetes cluster-init --env=` for each applicable environment, in order to install the required cluster-wide services. Those services include the Docker daemon itself, as well as an image registry, a sync service for receiving build contexts, two persistent volumes, an NFS volume provisioner for one of those volumes, and a couple of small utility services. Make sure your cluster has enough resources and storage to support the required services, and keep in mind that these -services are shared across all users of the cluster. Please look at the -[resources](../providers/kubernetes.md#providersresources) and -[storage](../providers/kubernetes.md#providersstorage) sections in the provider reference for +services are shared across all users of the cluster. Please look at the [resources](../providers/kubernetes.md#providersresources) and [storage](../providers/kubernetes.md#providersstorage) sections in the provider reference for details. ### Kaniko @@ -164,3 +160,24 @@ providers: This registry auth secret will then be copied and passed to the in-cluster builder. You can specify as many as you like, and they will be merged together. > Note: Any time you add or modify imagePullSecrets after first initializing your cluster, you need to run `garden plugins kubernetes cluster-init` again for them to work when pulling base images! + +## Using private registries for deployments + +You can also use your private registry to store images after building and for deployment. If you've completed the steps above, you can configure a `deploymentRegistry` in your provider configuration: + +```yaml +kind: Project +name: my-project +... +providers: + - name: kubernetes + ... + imagePullSecrets: + - name: my-registry-secret + namespace: default + deploymentRegistry: + hostname: my-private-registry.com + namespace: my-project # <--- make sure your configured imagePullSecrets can write to repos in this namespace +``` + +This is often more scalable than using the default in-cluster registry, and may fit better with existing deployment pipelines. Just make sure the configured `imagePullSecrets` have the privileges to push to repos in the configured namespace. diff --git a/docs/providers/kubernetes.md b/docs/providers/kubernetes.md index 00a5c733c0..f663727a5c 100644 --- a/docs/providers/kubernetes.md +++ b/docs/providers/kubernetes.md @@ -254,6 +254,10 @@ providers: # The kubectl context to use to connect to the Kubernetes cluster. context: # The registry where built containers should be pushed to, and then pulled to the cluster when deploying services. + # + # Important: If you specify this in combination with `buildMode: cluster-docker` or `buildMode: kaniko`, you must + # make sure `imagePullSecrets` includes authentication with the specified deployment registry, that has the + # appropriate write privileges (usually full write access to the configured `deploymentRegistry.namespace`). deploymentRegistry: # The hostname (and optionally port, if not the default port) of the registry. hostname: @@ -1283,6 +1287,8 @@ providers: The registry where built containers should be pushed to, and then pulled to the cluster when deploying services. +Important: If you specify this in combination with `buildMode: cluster-docker` or `buildMode: kaniko`, you must make sure `imagePullSecrets` includes authentication with the specified deployment registry, that has the appropriate write privileges (usually full write access to the configured `deploymentRegistry.namespace`). + | Type | Required | | -------- | -------- | | `object` | No | diff --git a/garden-service/src/plugins/container/config.ts b/garden-service/src/plugins/container/config.ts index 194c66d5d3..5273bc0c78 100644 --- a/garden-service/src/plugins/container/config.ts +++ b/garden-service/src/plugins/container/config.ts @@ -407,9 +407,10 @@ export const containerRegistryConfigSchema = joi.object().keys({ .default("_") .description("The namespace in the registry where images should be pushed.") .example("my-project"), -}).description(deline` - The registry where built containers should be pushed to, and then pulled to the cluster when deploying - services. +}).description(dedent` + The registry where built containers should be pushed to, and then pulled to the cluster when deploying services. + + Important: If you specify this in combination with \`buildMode: cluster-docker\` or \`buildMode: kaniko\`, you must make sure \`imagePullSecrets\` includes authentication with the specified deployment registry, that has the appropriate write privileges (usually full write access to the configured \`deploymentRegistry.namespace\`). `) export interface ContainerService extends Service {} diff --git a/garden-service/src/plugins/kubernetes/constants.ts b/garden-service/src/plugins/kubernetes/constants.ts index 02926fa647..1cc6931604 100644 --- a/garden-service/src/plugins/kubernetes/constants.ts +++ b/garden-service/src/plugins/kubernetes/constants.ts @@ -14,3 +14,4 @@ export const MAX_RUN_RESULT_OUTPUT_LENGTH = 900 * 1024 // max ConfigMap data siz export const dockerAuthSecretName = "builder-docker-config" export const dockerAuthSecretKey = ".dockerconfigjson" +export const inClusterRegistryHostname = "127.0.0.1:5000" diff --git a/garden-service/src/plugins/kubernetes/container/build.ts b/garden-service/src/plugins/kubernetes/container/build.ts index bfbf4469ef..f37c797231 100644 --- a/garden-service/src/plugins/kubernetes/container/build.ts +++ b/garden-service/src/plugins/kubernetes/container/build.ts @@ -14,7 +14,7 @@ import { buildContainerModule, getContainerBuildStatus, getDockerBuildFlags } fr import { GetBuildStatusParams, BuildStatus } from "../../../types/plugin/module/getBuildStatus" import { BuildModuleParams, BuildResult } from "../../../types/plugin/module/build" import { millicpuToString, megabytesToString, getRunningPodInDeployment, makePodName } from "../util" -import { RSYNC_PORT, dockerAuthSecretName, dockerAuthSecretKey } from "../constants" +import { RSYNC_PORT, dockerAuthSecretName, dockerAuthSecretKey, inClusterRegistryHostname } from "../constants" import { posix, resolve } from "path" import { KubeApi } from "../api" import { kubectl } from "../kubectl" @@ -246,11 +246,15 @@ const remoteBuild: BuildHandler = async (params) => { "--destination", deploymentImageId, "--cache=true", - "--insecure", // The in-cluster registry is not exposed, so we don't configure TLS on it. - // "--verbosity", "debug", - ...getDockerBuildFlags(module), ] + if (provider.config.deploymentRegistry?.hostname === inClusterRegistryHostname) { + // The in-cluster registry is not exposed, so we don't configure TLS on it. + args.push("--insecure") + } + + args.push(...getDockerBuildFlags(module)) + // Execute the build const buildRes = await runKaniko({ provider, log, module, args, outputStream: stdout }) buildLog = buildRes.log diff --git a/garden-service/src/plugins/kubernetes/kubernetes.ts b/garden-service/src/plugins/kubernetes/kubernetes.ts index 74c85c1b66..a0cc072ab1 100644 --- a/garden-service/src/plugins/kubernetes/kubernetes.ts +++ b/garden-service/src/plugins/kubernetes/kubernetes.ts @@ -34,6 +34,7 @@ import chalk from "chalk" import pluralize from "pluralize" import { getSystemMetadataNamespaceName } from "./system" import { removeTillerCmd } from "./commands/remove-tiller" +import { inClusterRegistryHostname } from "./constants" export async function configureProvider({ projectName, @@ -55,17 +56,19 @@ export async function configureProvider({ } if (config.buildMode === "cluster-docker" || config.buildMode === "kaniko") { - // TODO: support external registry - // This is a special configuration, used in combination with the registry-proxy service, - // to make sure every node in the cluster can resolve the image from the registry we deploy in-cluster. - config.deploymentRegistry = { - hostname: `127.0.0.1:5000`, - namespace: config.namespace, + config._systemServices.push("build-sync") + + if (!config.deploymentRegistry || config.deploymentRegistry.hostname === inClusterRegistryHostname) { + // Deploy an in-cluster registry, unless otherwise specified. + // This is a special configuration, used in combination with the registry-proxy service, + // to make sure every node in the cluster can resolve the image from the registry we deploy in-cluster. + config.deploymentRegistry = { + hostname: inClusterRegistryHostname, + namespace: config.namespace, + } + config._systemServices.push("docker-registry", "registry-proxy") } - // Deploy build services on init - config._systemServices.push("build-sync", "docker-registry", "registry-proxy") - if (config.buildMode === "cluster-docker") { config._systemServices.push("docker-daemon") } diff --git a/garden-service/src/plugins/kubernetes/local/config.ts b/garden-service/src/plugins/kubernetes/local/config.ts index f007897ecc..4313ea9233 100644 --- a/garden-service/src/plugins/kubernetes/local/config.ts +++ b/garden-service/src/plugins/kubernetes/local/config.ts @@ -131,9 +131,11 @@ export async function configureProvider(params: ConfigureProviderParams { let garden: Garden @@ -27,15 +21,6 @@ describe("k8sBuildContainer", () => { let provider: KubernetesProvider let ctx: PluginContext - let initialized = false - - const root = getDataDir("test-projects", "container") - - before(async () => { - garden = await makeTestGarden(root, { environmentName: "local" }) - provider = await garden.resolveProvider("local-kubernetes") - }) - after(async () => { if (garden) { await garden.close() @@ -43,46 +28,10 @@ describe("k8sBuildContainer", () => { }) const init = async (environmentName: string) => { - garden = await makeTestGarden(root, { environmentName }) - - if (!initialized && environmentName !== "local") { - // Load the test authentication for private registries - const api = await KubeApi.factory(garden.log, provider) - try { - const authSecret = JSON.parse( - (await decryptSecretFile(resolve(GARDEN_SERVICE_ROOT, "..", "secrets", "test-docker-auth.json"))).toString() - ) - await api.upsert({ kind: "Secret", namespace: "default", obj: authSecret, log: garden.log }) - } catch (err) { - // This is expected when running without access to gcloud (e.g. in minikube tests) - // tslint:disable-next-line: no-console - console.log("Warning: Unable to decrypt docker auth secret") - const authSecret: KubernetesResource = { - apiVersion: "v1", - kind: "Secret", - type: "kubernetes.io/dockerconfigjson", - metadata: { - name: "test-docker-auth", - namespace: "default", - }, - stringData: { - ".dockerconfigjson": JSON.stringify({ auths: {} }), - }, - } - await api.upsert({ kind: "Secret", namespace: "default", obj: authSecret, log: garden.log }) - } - } - + garden = await getContainerTestGarden(environmentName) graph = await garden.getConfigGraph(garden.log) provider = await garden.resolveProvider("local-kubernetes") ctx = garden.getPluginContext(provider) - - // We only need to run the cluster-init flow once, because the configurations are compatible - if (!initialized && environmentName !== "local") { - // Run cluster-init - await clusterInit.handler({ ctx, log: garden.log }) - initialized = true - } } context("local mode", () => { @@ -145,6 +94,34 @@ describe("k8sBuildContainer", () => { } ) }) + + it("should push to configured deploymentRegistry if specified (remote only)", async () => { + const module = await graph.getModule("private-base") + await garden.buildDir.syncFromSrc(module, garden.log) + + await k8sBuildContainer({ + ctx, + log: garden.log, + module, + }) + }) + }) + + context("cluster-docker-remote-registry mode", () => { + before(async () => { + await init("cluster-docker-remote-registry") + }) + + it("should push to configured deploymentRegistry if specified (remote only)", async () => { + const module = await graph.getModule("remote-registry-test") + await garden.buildDir.syncFromSrc(module, garden.log) + + await k8sBuildContainer({ + ctx, + log: garden.log, + module, + }) + }) }) context("cluster-docker mode with BuildKit", () => { @@ -239,4 +216,21 @@ describe("k8sBuildContainer", () => { ) }) }) + + context("kaniko-remote-registry mode", () => { + before(async () => { + await init("kaniko-remote-registry") + }) + + it("should push to configured deploymentRegistry if specified (remote only)", async () => { + const module = await graph.getModule("remote-registry-test") + await garden.buildDir.syncFromSrc(module, garden.log) + + await k8sBuildContainer({ + ctx, + log: garden.log, + module, + }) + }) + }) }) diff --git a/garden-service/test/integ/src/plugins/kubernetes/container/container.ts b/garden-service/test/integ/src/plugins/kubernetes/container/container.ts index bd981f9dc0..9df96bf352 100644 --- a/garden-service/test/integ/src/plugins/kubernetes/container/container.ts +++ b/garden-service/test/integ/src/plugins/kubernetes/container/container.ts @@ -11,7 +11,7 @@ import { getDataDir, makeTestGarden, expectError } from "../../../../../helpers" import { TestTask } from "../../../../../../src/tasks/test" import { emptyDir, pathExists } from "fs-extra" import { expect } from "chai" -import { join } from "path" +import { join, resolve } from "path" import { Garden } from "../../../../../../src/garden" import { ConfigGraph } from "../../../../../../src/config-graph" import { findByName } from "../../../../../../src/util/util" @@ -24,6 +24,65 @@ import { prepareRuntimeContext } from "../../../../../../src/runtime-context" import { KubeApi } from "../../../../../../src/plugins/kubernetes/api" import { KubernetesProvider } from "../../../../../../src/plugins/kubernetes/config" import { makePodName } from "../../../../../../src/plugins/kubernetes/util" +import { decryptSecretFile } from "../../../../helpers" +import { GARDEN_SERVICE_ROOT } from "../../../../../../src/constants" +import { KubernetesResource } from "../../../../../../src/plugins/kubernetes/types" +import { V1Secret } from "@kubernetes/client-node" +import { clusterInit } from "../../../../../../src/plugins/kubernetes/commands/cluster-init" + +const root = getDataDir("test-projects", "container") +let initialized = false +let localInstance: Garden + +export async function getContainerTestGarden(environmentName: string) { + const garden = await makeTestGarden(root, { environmentName }) + + if (!localInstance) { + localInstance = await makeTestGarden(root, { environmentName: "local" }) + } + + if (!initialized && environmentName !== "local") { + // Load the test authentication for private registries + const localProvider = await localInstance.resolveProvider("local-kubernetes") + const api = await KubeApi.factory(garden.log, localProvider) + + try { + const authSecret = JSON.parse( + (await decryptSecretFile(resolve(GARDEN_SERVICE_ROOT, "..", "secrets", "test-docker-auth.json"))).toString() + ) + await api.upsert({ kind: "Secret", namespace: "default", obj: authSecret, log: garden.log }) + } catch (err) { + // This is expected when running without access to gcloud (e.g. in minikube tests) + // tslint:disable-next-line: no-console + console.log("Warning: Unable to decrypt docker auth secret") + const authSecret: KubernetesResource = { + apiVersion: "v1", + kind: "Secret", + type: "kubernetes.io/dockerconfigjson", + metadata: { + name: "test-docker-auth", + namespace: "default", + }, + stringData: { + ".dockerconfigjson": JSON.stringify({ auths: {} }), + }, + } + await api.upsert({ kind: "Secret", namespace: "default", obj: authSecret, log: garden.log }) + } + } + + const provider = await garden.resolveProvider("local-kubernetes") + const ctx = garden.getPluginContext(provider) + + // We only need to run the cluster-init flow once, because the configurations are compatible + if (!initialized && environmentName !== "local") { + // Run cluster-init + await clusterInit.handler({ ctx, log: garden.log }) + initialized = true + } + + return garden +} describe("kubernetes container module handlers", () => { let garden: Garden @@ -32,7 +91,6 @@ describe("kubernetes container module handlers", () => { let namespace: string before(async () => { - const root = getDataDir("test-projects", "container") garden = await makeTestGarden(root) graph = await garden.getConfigGraph(garden.log) provider = await garden.resolveProvider("local-kubernetes") @@ -430,10 +488,6 @@ describe("kubernetes container module handlers", () => { runtimeContext, }) - // Logging to try to figure out why this test flakes sometimes - // tslint:disable-next-line: no-console - console.log(result) - expect(result.success).to.be.true expect(result.log.trim()).to.eql("ok") }) diff --git a/garden-service/test/integ/src/plugins/kubernetes/container/deployment.ts b/garden-service/test/integ/src/plugins/kubernetes/container/deployment.ts index da85392e38..b87396be13 100644 --- a/garden-service/test/integ/src/plugins/kubernetes/container/deployment.ts +++ b/garden-service/test/integ/src/plugins/kubernetes/container/deployment.ts @@ -6,7 +6,6 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { getDataDir, makeTestGarden } from "../../../../../helpers" import { expect } from "chai" import { Garden } from "../../../../../../src/garden" import { ConfigGraph } from "../../../../../../src/config-graph" @@ -16,7 +15,10 @@ import { createWorkloadManifest } from "../../../../../../src/plugins/kubernetes import { KubernetesProvider } from "../../../../../../src/plugins/kubernetes/config" import { V1Secret } from "@kubernetes/client-node" import { KubernetesResource } from "../../../../../../src/plugins/kubernetes/types" -import { cloneDeep } from "lodash" +import { cloneDeep, keyBy } from "lodash" +import { getContainerTestGarden } from "./container" +import { DeployTask } from "../../../../../../src/tasks/deploy" +import { getServiceStatuses } from "../../../../../../src/tasks/base" describe("kubernetes container deployment handlers", () => { let garden: Garden @@ -24,19 +26,25 @@ describe("kubernetes container deployment handlers", () => { let provider: KubernetesProvider let api: KubeApi - before(async () => { - const root = getDataDir("test-projects", "container") - garden = await makeTestGarden(root) + after(async () => { + if (garden) { + await garden.close() + } + }) + + const init = async (environmentName: string) => { + garden = await getContainerTestGarden(environmentName) + graph = await garden.getConfigGraph(garden.log) provider = await garden.resolveProvider("local-kubernetes") api = await KubeApi.factory(garden.log, provider) - }) - - after(async () => { - await garden.close() - }) + } describe("createWorkloadManifest", () => { + before(async () => { + await init("local") + }) + it("should create a basic Deployment resource", async () => { const service = await graph.getService("simple-service") @@ -135,4 +143,141 @@ describe("kubernetes container deployment handlers", () => { expect(resource.spec.template.spec.imagePullSecrets).to.eql([{ name: secretName }]) }) }) + + describe("deployContainerService", () => { + context("local mode", () => { + before(async () => { + await init("local") + }) + + it("should deploy a simple service", async () => { + const service = await graph.getService("simple-service") + + const deployTask = new DeployTask({ + garden, + graph, + log: garden.log, + service, + force: true, + forceBuild: false, + }) + + const results = await garden.processTasks([deployTask], { throwOnError: true }) + const statuses = getServiceStatuses(results) + const status = statuses[service.name] + const resources = keyBy(status.detail["remoteResources"], "kind") + expect(resources.Deployment.spec.template.spec.containers[0].image).to.equal( + `${service.name}:${service.module.version.versionString}` + ) + }) + }) + + context("cluster-docker mode", () => { + before(async () => { + await init("cluster-docker") + }) + + it("should deploy a simple service", async () => { + const service = await graph.getService("simple-service") + + const deployTask = new DeployTask({ + garden, + graph, + log: garden.log, + service, + force: true, + forceBuild: false, + }) + + const results = await garden.processTasks([deployTask], { throwOnError: true }) + const statuses = getServiceStatuses(results) + const status = statuses[service.name] + const resources = keyBy(status.detail["remoteResources"], "kind") + expect(resources.Deployment.spec.template.spec.containers[0].image).to.equal( + `127.0.0.1:5000/container/${service.name}:${service.module.version.versionString}` + ) + }) + }) + + context("kaniko mode", () => { + before(async () => { + await init("kaniko") + }) + + it("should deploy a simple service", async () => { + const service = await graph.getService("simple-service") + + const deployTask = new DeployTask({ + garden, + graph, + log: garden.log, + service, + force: true, + forceBuild: false, + }) + + const results = await garden.processTasks([deployTask], { throwOnError: true }) + const statuses = getServiceStatuses(results) + const status = statuses[service.name] + const resources = keyBy(status.detail["remoteResources"], "kind") + expect(resources.Deployment.spec.template.spec.containers[0].image).to.equal( + `127.0.0.1:5000/container/${service.name}:${service.module.version.versionString}` + ) + }) + }) + + context("cluster-docker-remote-registry mode", () => { + before(async () => { + await init("cluster-docker-remote-registry") + }) + + it("should deploy a simple service (remote only)", async () => { + const service = await graph.getService("remote-registry-test") + + const deployTask = new DeployTask({ + garden, + graph, + log: garden.log, + service, + force: true, + forceBuild: false, + }) + + const results = await garden.processTasks([deployTask], { throwOnError: true }) + const statuses = getServiceStatuses(results) + const status = statuses[service.name] + const resources = keyBy(status.detail["remoteResources"], "kind") + expect(resources.Deployment.spec.template.spec.containers[0].image).to.equal( + `index.docker.io/gardendev/${service.name}:${service.module.version.versionString}` + ) + }) + }) + + context("kaniko-remote-registry mode", () => { + before(async () => { + await init("kaniko-remote-registry") + }) + + it("should deploy a simple service (remote only)", async () => { + const service = await graph.getService("remote-registry-test") + + const deployTask = new DeployTask({ + garden, + graph, + log: garden.log, + service, + force: true, + forceBuild: false, + }) + + const results = await garden.processTasks([deployTask], { throwOnError: true }) + const statuses = getServiceStatuses(results) + const status = statuses[service.name] + const resources = keyBy(status.detail["remoteResources"], "kind") + expect(resources.Deployment.spec.template.spec.containers[0].image).to.equal( + `index.docker.io/gardendev/${service.name}:${service.module.version.versionString}` + ) + }) + }) + }) }) diff --git a/garden-service/test/integ/src/plugins/kubernetes/container/logs.ts b/garden-service/test/integ/src/plugins/kubernetes/container/logs.ts index b73bc1547e..fa9ad31881 100644 --- a/garden-service/test/integ/src/plugins/kubernetes/container/logs.ts +++ b/garden-service/test/integ/src/plugins/kubernetes/container/logs.ts @@ -68,6 +68,8 @@ describe("kubernetes", () => { tail: -1, }) + console.log(entries) + expect(entries[0].msg).to.include("Server running...") }) }) diff --git a/garden-service/test/integ/src/plugins/kubernetes/util.ts b/garden-service/test/integ/src/plugins/kubernetes/util.ts index 4390553ae7..cee94d1919 100644 --- a/garden-service/test/integ/src/plugins/kubernetes/util.ts +++ b/garden-service/test/integ/src/plugins/kubernetes/util.ts @@ -9,7 +9,7 @@ import { expect } from "chai" import { flatten, find, first } from "lodash" import stripAnsi from "strip-ansi" -import { getDataDir, makeTestGarden, TestGarden, expectError } from "../../../../helpers" +import { TestGarden, expectError } from "../../../../helpers" import { ConfigGraph } from "../../../../../src/config-graph" import { Provider } from "../../../../../src/config/provider" import { DeployTask } from "../../../../../src/tasks/deploy" @@ -31,6 +31,7 @@ import { buildHelmModule } from "../../../../../src/plugins/kubernetes/helm/buil import { HotReloadableResource } from "../../../../../src/plugins/kubernetes/hot-reload" import { LogEntry } from "../../../../../src/logger/log-entry" import { BuildTask } from "../../../../../src/tasks/build" +import { getContainerTestGarden } from "./container/container" describe("util", () => { let helmGarden: TestGarden @@ -72,8 +73,7 @@ describe("util", () => { // TODO: Add more test cases describe("getWorkloadPods", () => { it("should return workload pods", async () => { - const root = getDataDir("test-projects", "container") - const garden = await makeTestGarden(root) + const garden = await getContainerTestGarden("local") try { const graph = await garden.getConfigGraph(garden.log)