diff --git a/garden-service/src/actions.ts b/garden-service/src/actions.ts index a13ef97819..27acc9d80c 100644 --- a/garden-service/src/actions.ts +++ b/garden-service/src/actions.ts @@ -69,6 +69,8 @@ import { RunTaskParams, RunTaskResult } from "./types/plugin/task/runTask" import { Service, ServiceStatus, ServiceStatusMap, getServiceRuntimeContext } from "./types/service" import { Omit } from "./util/util" import { DebugInfoMap } from "./types/plugin/provider/getDebugInfo" +import { GetPortForwardParams } from "./types/plugin/service/getPortForward" +import { StopPortForwardParams } from "./types/plugin/service/stopPortForward" type TypeGuard = { readonly [P in keyof (PluginActionParams | ModuleActionParams)]: (...args: any[]) => Promise @@ -345,6 +347,14 @@ export class ActionHelper implements TypeGuard { return this.callServiceHandler({ params, actionType: "runService" }) } + async getPortForward(params: ServiceActionHelperParams) { + return this.callServiceHandler({ params, actionType: "getPortForward" }) + } + + async stopPortForward(params: ServiceActionHelperParams) { + return this.callServiceHandler({ params, actionType: "stopPortForward" }) + } + //endregion //=========================================================================== diff --git a/garden-service/src/cli/cli.ts b/garden-service/src/cli/cli.ts index 33c20bbf0e..3ecc0742c3 100644 --- a/garden-service/src/cli/cli.ts +++ b/garden-service/src/cli/cli.ts @@ -283,7 +283,7 @@ export class GardenCli { let garden: Garden let result: any - await command.prepare({ + const { persistent } = await command.prepare({ log, headerLog, footerLog, @@ -291,6 +291,8 @@ export class GardenCli { opts: parsedOpts, }) + contextOpts.persistent = persistent + do { try { garden = await Garden.factory(root, contextOpts) diff --git a/garden-service/src/commands/base.ts b/garden-service/src/commands/base.ts index 669a14bd1e..9f77e301ef 100644 --- a/garden-service/src/commands/base.ts +++ b/garden-service/src/commands/base.ts @@ -238,6 +238,11 @@ export interface CommandParams { abstract name: string abstract help: string @@ -302,7 +307,8 @@ export abstract class Command) { + async prepare(_: PrepareParams): Promise { + return { persistent: false } } // Note: Due to a current TS limitation (apparently covered by https://github.com/Microsoft/TypeScript/issues/7011), diff --git a/garden-service/src/commands/build.ts b/garden-service/src/commands/build.ts index 86114c5e97..12c73d5482 100644 --- a/garden-service/src/commands/build.ts +++ b/garden-service/src/commands/build.ts @@ -64,9 +64,13 @@ export class BuildCommand extends Command { async prepare({ headerLog, footerLog, opts }: PrepareParams) { printHeader(headerLog, "Build", "hammer") - if (!!opts.watch) { + const persistent = !!opts.watch + + if (persistent) { this.server = await startServer(footerLog) } + + return { persistent } } async action( diff --git a/garden-service/src/commands/deploy.ts b/garden-service/src/commands/deploy.ts index 791dedc731..78c0ee6e47 100644 --- a/garden-service/src/commands/deploy.ts +++ b/garden-service/src/commands/deploy.ts @@ -87,9 +87,13 @@ export class DeployCommand extends Command { async prepare({ headerLog, footerLog, opts }: PrepareParams) { printHeader(headerLog, "Deploy", "rocket") - if (!!opts.watch || !!opts["hot-reload"]) { + const persistent = !!opts.watch || !!opts["hot-reload"] + + if (persistent) { this.server = await startServer(footerLog) } + + return { persistent } } async action({ garden, log, footerLog, args, opts }: CommandParams): Promise> { diff --git a/garden-service/src/commands/dev.ts b/garden-service/src/commands/dev.ts index afd92ec12c..ae6f29f321 100644 --- a/garden-service/src/commands/dev.ts +++ b/garden-service/src/commands/dev.ts @@ -96,6 +96,8 @@ export class DevCommand extends Command { log.info(chalk.gray.italic(`Good ${getGreetingTime()}! Let's get your environment wired up...\n`)) this.server = await startServer(footerLog) + + return { persistent: true } } async action({ garden, log, footerLog, opts }: CommandParams): Promise { diff --git a/garden-service/src/commands/get/get-tasks.ts b/garden-service/src/commands/get/get-tasks.ts index 7244bbf5dc..434a3d4a7f 100644 --- a/garden-service/src/commands/get/get-tasks.ts +++ b/garden-service/src/commands/get/get-tasks.ts @@ -63,6 +63,7 @@ export class GetTasksCommand extends Command { async prepare({ headerLog }: PrepareParams) { printHeader(headerLog, "Tasks", "open_book") + return { persistent: false } } async action({ args, garden, log }: CommandParams): Promise { diff --git a/garden-service/src/commands/serve.ts b/garden-service/src/commands/serve.ts index 1e37faa127..b65f05ad7f 100644 --- a/garden-service/src/commands/serve.ts +++ b/garden-service/src/commands/serve.ts @@ -45,6 +45,7 @@ export class ServeCommand extends Command { async prepare({ footerLog, opts }: PrepareParams) { this.server = await startServer(footerLog, opts.port) + return { persistent: true } } async action({ garden }: CommandParams): Promise> { diff --git a/garden-service/src/commands/test.ts b/garden-service/src/commands/test.ts index ce7d4a45ca..d332553e78 100644 --- a/garden-service/src/commands/test.ts +++ b/garden-service/src/commands/test.ts @@ -81,9 +81,13 @@ export class TestCommand extends Command { async prepare({ headerLog, footerLog, opts }: PrepareParams) { printHeader(headerLog, `Running tests`, "thermometer") - if (!!opts.watch) { + const persistent = !!opts.watch + + if (persistent) { this.server = await startServer(footerLog) } + + return { persistent } } async action({ garden, log, footerLog, args, opts }: CommandParams): Promise> { diff --git a/garden-service/src/garden.ts b/garden-service/src/garden.ts index 42be980508..757469213a 100644 --- a/garden-service/src/garden.ts +++ b/garden-service/src/garden.ts @@ -72,6 +72,7 @@ export interface GardenOpts { config?: ProjectConfig, gardenDirPath?: string, environmentName?: string, + persistent?: boolean, log?: LogEntry, plugins?: Plugins, } @@ -130,6 +131,7 @@ export class Garden { public readonly dotIgnoreFiles: string[] public readonly moduleIncludePatterns?: string[] public readonly moduleExcludePatterns: string[] + public readonly persistent: boolean constructor(params: GardenParams) { this.buildDir = params.buildDir @@ -145,6 +147,7 @@ export class Garden { this.dotIgnoreFiles = params.dotIgnoreFiles this.moduleIncludePatterns = params.moduleIncludePatterns this.moduleExcludePatterns = params.moduleExcludePatterns || [] + this.persistent = !!params.opts.persistent // make sure we're on a supported platform const currentPlatform = platform() diff --git a/garden-service/src/plugins/google/google-cloud-functions.ts b/garden-service/src/plugins/google/google-cloud-functions.ts index 9402026d8e..492930b00d 100644 --- a/garden-service/src/plugins/google/google-cloud-functions.ts +++ b/garden-service/src/plugins/google/google-cloud-functions.ts @@ -151,8 +151,8 @@ export async function getServiceStatus( const state: ServiceState = status.status === "ACTIVE" ? "ready" : "unhealthy" return { - providerId, - providerVersion: status.versionId, + externalId: providerId, + externalVersion: status.versionId, version: status.labels[gardenAnnotationKey("version")], state, updatedAt: status.updateTime, diff --git a/garden-service/src/plugins/kubernetes/container/build.ts b/garden-service/src/plugins/kubernetes/container/build.ts index 6ba69bc1c1..c1fab72513 100644 --- a/garden-service/src/plugins/kubernetes/container/build.ts +++ b/garden-service/src/plugins/kubernetes/container/build.ts @@ -12,7 +12,7 @@ import { containerHelpers } from "../../container/helpers" import { buildContainerModule, getContainerBuildStatus, getDockerBuildFlags } from "../../container/build" import { GetBuildStatusParams, BuildStatus } from "../../../types/plugin/module/getBuildStatus" import { BuildModuleParams, BuildResult } from "../../../types/plugin/module/build" -import { getPortForward, getPods, millicpuToString, megabytesToString } from "../util" +import { getPods, millicpuToString, megabytesToString } from "../util" import { systemNamespace } from "../system" import { RSYNC_PORT } from "../constants" import execa = require("execa") @@ -26,6 +26,7 @@ import { runPod } from "../run" import { getRegistryHostname } from "../init" import { getManifestFromRegistry } from "./util" import { normalizeLocalRsyncPath } from "../../../util/fs" +import { getPortForward } from "../port-forward" const dockerDaemonDeploymentName = "garden-docker-daemon" const dockerDaemonContainerName = "docker-daemon" @@ -130,7 +131,7 @@ const remoteBuild: BuildHandler = async (params) => { ctx, log, namespace: systemNamespace, - targetDeployment: `Deployment/${buildSyncDeploymentName}`, + targetResource: `Deployment/${buildSyncDeploymentName}`, port: RSYNC_PORT, }) diff --git a/garden-service/src/plugins/kubernetes/container/handlers.ts b/garden-service/src/plugins/kubernetes/container/handlers.ts index 0d1ab6087a..fa5c085807 100644 --- a/garden-service/src/plugins/kubernetes/container/handlers.ts +++ b/garden-service/src/plugins/kubernetes/container/handlers.ts @@ -22,6 +22,7 @@ import { configureMavenContainerModule, MavenContainerModule } from "../../maven import { getTaskResult } from "../task-results" import { k8sBuildContainer, k8sGetContainerBuildStatus } from "./build" import { k8sPublishContainerModule } from "./publish" +import { getPortForwardHandler } from "../port-forward" async function configure(params: ConfigureModuleParams) { params.moduleConfig = await configureContainerModule(params) @@ -41,6 +42,7 @@ export const containerHandlers = { deleteService, execInService, getBuildStatus: k8sGetContainerBuildStatus, + getPortForward: getPortForwardHandler, getServiceLogs, getServiceStatus: getContainerServiceStatus, getTestResult, diff --git a/garden-service/src/plugins/kubernetes/container/status.ts b/garden-service/src/plugins/kubernetes/container/status.ts index 149a4246ef..b7f9aac276 100644 --- a/garden-service/src/plugins/kubernetes/container/status.ts +++ b/garden-service/src/plugins/kubernetes/container/status.ts @@ -8,7 +8,7 @@ import { PluginContext } from "../../../plugin-context" import { LogEntry } from "../../../logger/log-entry" -import { RuntimeContext, Service, ServiceStatus } from "../../../types/service" +import { RuntimeContext, Service, ServiceStatus, ForwardablePort } from "../../../types/service" import { createContainerObjects } from "./deployment" import { KUBECTL_DEFAULT_TIMEOUT } from "../kubectl" import { DeploymentError } from "../../../exceptions" @@ -37,7 +37,20 @@ export async function getContainerServiceStatus( const { state, remoteObjects } = await compareDeployedObjects(k8sCtx, api, namespace, objects, log, true) const ingresses = await getIngresses(service, api, provider) + const forwardablePorts: ForwardablePort[] = service.spec.ports + .filter(p => p.protocol === "TCP") + .map(p => { + return { + name: p.name, + protocol: "TCP", + targetPort: p.servicePort, + // TODO: this needs to be configurable + // urlProtocol: "http", + } + }) + return { + forwardablePorts, ingresses, state, version: state === "ready" ? version.versionString : undefined, diff --git a/garden-service/src/plugins/kubernetes/container/util.ts b/garden-service/src/plugins/kubernetes/container/util.ts index e0a9173dae..f346dbcff1 100644 --- a/garden-service/src/plugins/kubernetes/container/util.ts +++ b/garden-service/src/plugins/kubernetes/container/util.ts @@ -8,7 +8,7 @@ import { resolve } from "url" import { ContainerModule } from "../../container/config" -import { getPortForward } from "../util" +import { getPortForward } from "../port-forward" import { systemNamespace } from "../system" import { CLUSTER_REGISTRY_DEPLOYMENT_NAME, CLUSTER_REGISTRY_PORT } from "../constants" import { containerHelpers } from "../../container/helpers" @@ -33,7 +33,7 @@ export async function getRegistryPortForward(ctx: PluginContext, log: LogEntry) ctx, log, namespace: systemNamespace, - targetDeployment: `Deployment/${CLUSTER_REGISTRY_DEPLOYMENT_NAME}`, + targetResource: `Deployment/${CLUSTER_REGISTRY_DEPLOYMENT_NAME}`, port: CLUSTER_REGISTRY_PORT, }) } diff --git a/garden-service/src/plugins/kubernetes/helm/deployment.ts b/garden-service/src/plugins/kubernetes/helm/deployment.ts index 00f0ee60af..522274a152 100644 --- a/garden-service/src/plugins/kubernetes/helm/deployment.ts +++ b/garden-service/src/plugins/kubernetes/helm/deployment.ts @@ -27,6 +27,7 @@ import { ContainerHotReloadSpec } from "../../container/config" import { getHotReloadSpec } from "./hot-reload" import { DeployServiceParams } from "../../../types/plugin/service/deployService" import { DeleteServiceParams } from "../../../types/plugin/service/deleteService" +import { getForwardablePorts } from "../port-forward" export async function deployService( { ctx, module, service, log, force, hotReload }: DeployServiceParams, @@ -100,7 +101,13 @@ export async function deployService( // they may be legitimately inconsistent. await waitForResources({ ctx, provider, serviceName: service.name, resources: chartResources, log }) - return {} + const forwardablePorts = getForwardablePorts(chartResources) + + return { + forwardablePorts, + state: "ready", + version: module.version.versionString, + } } export async function deleteService(params: DeleteServiceParams): Promise { diff --git a/garden-service/src/plugins/kubernetes/helm/handlers.ts b/garden-service/src/plugins/kubernetes/helm/handlers.ts index 2d3c3724d4..bfa0dda3ac 100644 --- a/garden-service/src/plugins/kubernetes/helm/handlers.ts +++ b/garden-service/src/plugins/kubernetes/helm/handlers.ts @@ -18,6 +18,7 @@ import { getServiceLogs } from "./logs" import { testHelmModule } from "./test" import { dedent } from "../../../util/string" import { joi } from "../../../config/common" +import { getPortForwardHandler } from "../port-forward" const helmModuleOutputsSchema = joi.object() .keys({ @@ -44,6 +45,7 @@ export const helmHandlers: Partial> = { deleteService, deployService, describeType, + getPortForward: getPortForwardHandler, getServiceLogs, getServiceStatus, getTestResult, diff --git a/garden-service/src/plugins/kubernetes/helm/status.ts b/garden-service/src/plugins/kubernetes/helm/status.ts index 9c751a5abd..38b8a71f45 100644 --- a/garden-service/src/plugins/kubernetes/helm/status.ts +++ b/garden-service/src/plugins/kubernetes/helm/status.ts @@ -20,6 +20,7 @@ import { buildHelmModule } from "./build" import { configureHotReload } from "../hot-reload" import { getHotReloadSpec } from "./hot-reload" import { KubernetesPluginContext } from "../config" +import { getForwardablePorts } from "../port-forward" const helmStatusCodeMap: { [code: number]: ServiceState } = { // see https://github.com/kubernetes/helm/blob/master/_proto/hapi/release/status.proto @@ -63,10 +64,15 @@ export async function getServiceStatus( const provider = k8sCtx.provider const api = await KubeApi.factory(log, provider.config.context) const namespace = await getAppNamespace(k8sCtx, log, provider) + let { state, remoteObjects } = await compareDeployedObjects(k8sCtx, api, namespace, chartResources, log, false) + + const forwardablePorts = getForwardablePorts(remoteObjects) + const detail = { remoteObjects } return { + forwardablePorts, state, version: state === "ready" ? module.version.versionString : undefined, detail, diff --git a/garden-service/src/plugins/kubernetes/hot-reload.ts b/garden-service/src/plugins/kubernetes/hot-reload.ts index 1650e4b91c..dc8861292c 100644 --- a/garden-service/src/plugins/kubernetes/hot-reload.ts +++ b/garden-service/src/plugins/kubernetes/hot-reload.ts @@ -19,7 +19,7 @@ import { Service } from "../../types/service" import { LogEntry } from "../../logger/log-entry" import { getResourceContainer } from "./helm/common" import { waitForContainerService } from "./container/status" -import { getPortForward, killPortForward } from "./util" +import { getPortForward, killPortForward } from "./port-forward" import { RSYNC_PORT } from "./constants" import { getAppNamespace } from "./namespace" import { KubernetesPluginContext } from "./config" @@ -224,14 +224,6 @@ function rsyncTargetPath(path: string) { /** * Ensure a tunnel is set up for connecting to the target service's sync container, and perform a sync. - * - * Before performing a sync, we set up a port-forward from a randomly allocated local port to the rsync sidecar - * container attached to the target service's container. - * - * Since hot-reloading is a time-sensitive operation for the end-user, and because setting up this port-forward - * can take several tens of milliseconds, we maintain a simple in-process cache of previously allocated ports. - * Therefore, subsequent hot reloads after the initial one (during the execution - * of the enclosing Garden command) finish more quickly. */ export async function syncToService( ctx: KubernetesPluginContext, @@ -241,11 +233,11 @@ export async function syncToService( targetName: string, log: LogEntry, ) { - const targetDeployment = `${targetKind.toLowerCase()}/${targetName}` + const targetResource = `${targetKind.toLowerCase()}/${targetName}` const namespace = await getAppNamespace(ctx, log, ctx.provider) const doSync = async () => { - const portForward = await getPortForward({ ctx, log, namespace, targetDeployment, port: RSYNC_PORT }) + const portForward = await getPortForward({ ctx, log, namespace, targetResource, port: RSYNC_PORT }) return Bluebird.map(hotReloadSpec.sync, ({ source, target }) => { const src = rsyncSourcePath(service.sourceModule.path, source) @@ -262,8 +254,8 @@ export async function syncToService( await doSync() } catch (error) { if (error.message.includes("did not see server greeting")) { - log.debug(`Port-forward to ${targetDeployment} disconnected. Retrying.`) - killPortForward(targetDeployment, RSYNC_PORT) + log.debug(`Port-forward to ${targetResource} disconnected. Retrying.`) + killPortForward(targetResource, RSYNC_PORT) await doSync() } else { throw error diff --git a/garden-service/src/plugins/kubernetes/kubernetes-module/handlers.ts b/garden-service/src/plugins/kubernetes/kubernetes-module/handlers.ts index 5b3161d1fe..32843641d0 100644 --- a/garden-service/src/plugins/kubernetes/kubernetes-module/handlers.ts +++ b/garden-service/src/plugins/kubernetes/kubernetes-module/handlers.ts @@ -28,6 +28,7 @@ import { DeployServiceParams } from "../../../types/plugin/service/deployService import { DeleteServiceParams } from "../../../types/plugin/service/deleteService" import { GetServiceLogsParams } from "../../../types/plugin/service/getServiceLogs" import { gardenAnnotationKey } from "../../../util/string" +import { getForwardablePorts, getPortForwardHandler } from "../port-forward" export const kubernetesHandlers: Partial> = { build, @@ -35,6 +36,7 @@ export const kubernetesHandlers: Partial + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { ChildProcess } from "child_process" + +import getPort = require("get-port") +const AsyncLock = require("async-lock") +import { V1Service } from "@kubernetes/client-node" + +import { GetPortForwardParams, GetPortForwardResult } from "../../types/plugin/service/getPortForward" +import { KubernetesProvider, KubernetesPluginContext } from "./config" +import { getAppNamespace } from "./namespace" +import { registerCleanupFunction } from "../../util/util" +import { PluginContext } from "../../plugin-context" +import { kubectl } from "./kubectl" +import { KubernetesResource } from "./types" +import { ForwardablePort } from "../../types/service" +import { isBuiltIn } from "./util" +import { LogEntry } from "../../logger/log-entry" + +// TODO: implement stopPortForward handler + +export interface PortForward { + targetResource: string + port: number + localPort: number + proc: ChildProcess +} + +const registeredPortForwards: { [key: string]: PortForward } = {} +const portForwardRegistrationLock = new AsyncLock() + +registerCleanupFunction("kill-port-forward-procs", () => { + for (const { targetResource, port } of Object.values(registeredPortForwards)) { + killPortForward(targetResource, port) + } +}) + +export function killPortForward(targetResource: string, port: number) { + const key = getPortForwardKey(targetResource, port) + const fwd = registeredPortForwards[key] + if (fwd) { + const { proc } = fwd + !proc.killed && proc.kill() + } +} + +function getPortForwardKey(targetResource: string, port: number) { + return `${targetResource}:${port}` +} + +/** + * Creates or re-uses an existing tunnel to a Kubernetes resources. + * + * We maintain a simple in-process cache of randomly allocated local ports that have been port-forwarded to a + * given port on a given Kubernetes resource. + */ +export async function getPortForward( + { ctx, log, namespace, targetResource, port }: + { ctx: PluginContext, log: LogEntry, namespace: string, targetResource: string, port: number }, +): Promise { + // Using lock here to avoid concurrency issues (multiple parallel requests for same forward). + const key = getPortForwardKey(targetResource, port) + + return portForwardRegistrationLock.acquire("register-port-forward", (async () => { + let localPort: number + + const registered = registeredPortForwards[key] + + if (registered && !registered.proc.killed) { + log.debug(`Reusing local port ${registered.localPort} for ${targetResource}`) + return registered + } + + const k8sCtx = ctx + + // Forward random free local port to the remote rsync container. + localPort = await getPort() + const portMapping = `${localPort}:${port}` + + log.debug(`Forwarding local port ${localPort} to ${targetResource} port ${port}`) + + // TODO: use the API directly instead of kubectl (need to reverse engineer kubectl a bit to get how that works) + const portForwardArgs = ["port-forward", targetResource, portMapping] + log.silly(`Running 'kubectl ${portForwardArgs.join(" ")}'`) + + const proc = await kubectl.spawn({ log, context: k8sCtx.provider.config.context, namespace, args: portForwardArgs }) + + return new Promise((resolve) => { + proc.on("error", (error) => { + !proc.killed && proc.kill() + throw error + }) + + proc.stdout!.on("data", (line) => { + // This is unfortunately the best indication that we have that the connection is up... + log.silly(`[${targetResource} port forwarder] ${line}`) + + if (line.toString().includes("Forwarding from ")) { + const portForward = { targetResource, port, proc, localPort } + registeredPortForwards[key] = portForward + resolve(portForward) + } + }) + }) + })) +} + +export async function getPortForwardHandler( + { ctx, log, service, targetPort }: GetPortForwardParams, +): Promise { + const provider = ctx.provider as KubernetesProvider + const namespace = await getAppNamespace(ctx, log, provider) + const targetResource = `Service/${service.name}` + + const fwd = await getPortForward({ ctx, log, namespace, targetResource, port: targetPort }) + + return { + hostname: "localhost", + port: fwd.localPort, + } +} + +/** + * Returns a list of forwardable ports based on the specified resources. + */ +export function getForwardablePorts(resources: KubernetesResource[]) { + const ports: ForwardablePort[] = [] + + for (const resource of resources) { + if (isBuiltIn(resource) && resource.kind === "Service") { + const service = resource as V1Service + + for (const portSpec of service.spec!.ports || []) { + ports.push({ + name: portSpec.name, + // TODO: not sure if/how possible but it would be good to deduce the protocol somehow + protocol: "TCP", + targetHostname: service.metadata!.name, + targetPort: portSpec.port, + }) + } + } + } + + return ports +} diff --git a/garden-service/src/plugins/kubernetes/util.ts b/garden-service/src/plugins/kubernetes/util.ts index 30f08a6f16..b8b2253ff2 100644 --- a/garden-service/src/plugins/kubernetes/util.ts +++ b/garden-service/src/plugins/kubernetes/util.ts @@ -8,19 +8,11 @@ import * as Bluebird from "bluebird" import { get, flatten, uniqBy, sortBy } from "lodash" -import { ChildProcess } from "child_process" -import getPort = require("get-port") -const AsyncLock = require("async-lock") import { V1Pod, V1EnvVar } from "@kubernetes/client-node" import { KubernetesResource, KubernetesWorkload, KubernetesPod, KubernetesServerResource } from "./types" import { splitLast, serializeValues } from "../../util/util" import { KubeApi, KubernetesError } from "./api" -import { PluginContext } from "../../plugin-context" -import { LogEntry } from "../../logger/log-entry" -import { KubernetesPluginContext } from "./config" -import { kubectl } from "./kubectl" -import { registerCleanupFunction } from "../../util/util" import { gardenAnnotationKey, base64 } from "../../util/string" import { MAX_CONFIGMAP_DATA_SIZE } from "./constants" import { ContainerEnvVars } from "../container/config" @@ -136,86 +128,6 @@ export function deduplicateResources(resources: KubernetesResource[]) { return uniqBy(resources, r => `${r.apiVersion}/${r.kind}`) } -export interface PortForward { - targetDeployment: string - port: number - localPort: number - proc: ChildProcess -} - -const registeredPortForwards: { [key: string]: PortForward } = {} -const portForwardRegistrationLock = new AsyncLock() - -registerCleanupFunction("kill-port-forward-procs", () => { - for (const { targetDeployment, port } of Object.values(registeredPortForwards)) { - killPortForward(targetDeployment, port) - } -}) - -export function killPortForward(targetDeployment: string, port: number) { - const key = getPortForwardKey(targetDeployment, port) - const fwd = registeredPortForwards[key] - if (fwd) { - const { proc } = fwd - !proc.killed && proc.kill() - } -} - -function getPortForwardKey(targetDeployment: string, port: number) { - return `${targetDeployment}:${port}` -} - -export async function getPortForward( - { ctx, log, namespace, targetDeployment, port }: - { ctx: PluginContext, log: LogEntry, namespace: string, targetDeployment: string, port: number }, -): Promise { - // Using lock here to avoid concurrency issues (multiple parallel requests for same forward). - const key = getPortForwardKey(targetDeployment, port) - - return portForwardRegistrationLock.acquire("register-port-forward", (async () => { - let localPort: number - - const registered = registeredPortForwards[key] - - if (registered && !registered.proc.killed) { - log.debug(`Reusing local port ${registered.localPort} for ${targetDeployment} container`) - return registered - } - - const k8sCtx = ctx - - // Forward random free local port to the remote rsync container. - localPort = await getPort() - const portMapping = `${localPort}:${port}` - - log.debug(`Forwarding local port ${localPort} to ${targetDeployment} container port ${port}`) - - // TODO: use the API directly instead of kubectl (need to reverse engineer kubectl a bit to get how that works) - const portForwardArgs = ["port-forward", targetDeployment, portMapping] - log.silly(`Running 'kubectl ${portForwardArgs.join(" ")}'`) - - const proc = await kubectl.spawn({ log, context: k8sCtx.provider.config.context, namespace, args: portForwardArgs }) - - return new Promise((resolve) => { - proc.on("error", (error) => { - !proc.killed && proc.kill() - throw error - }) - - proc.stdout!.on("data", (line) => { - // This is unfortunately the best indication that we have that the connection is up... - log.silly(`[${targetDeployment} port forwarder] ${line}`) - - if (line.toString().includes("Forwarding from ")) { - const portForward = { targetDeployment, port, proc, localPort } - registeredPortForwards[key] = portForward - resolve(portForward) - } - }) - }) - })) -} - /** * Converts the given number of millicpus (1000 mcpu = 1 CPU) to a string suitable for use in pod resource limit specs. */ diff --git a/garden-service/src/plugins/local/local-docker-swarm.ts b/garden-service/src/plugins/local/local-docker-swarm.ts index 6cd995df0b..83a2d39b86 100644 --- a/garden-service/src/plugins/local/local-docker-swarm.ts +++ b/garden-service/src/plugins/local/local-docker-swarm.ts @@ -117,8 +117,8 @@ export const gardenPlugin = (): GardenPlugin => ({ let swarmServiceStatus let serviceId - if (serviceStatus.providerId) { - const swarmService = await docker.getService(serviceStatus.providerId) + if (serviceStatus.externalId) { + const swarmService = await docker.getService(serviceStatus.externalId) swarmServiceStatus = await swarmService.inspect() opts.version = parseInt(swarmServiceStatus.Version.Index, 10) log.verbose({ @@ -126,7 +126,7 @@ export const gardenPlugin = (): GardenPlugin => ({ msg: `Updating existing Swarm service (version ${opts.version})`, }) await swarmService.update(opts) - serviceId = serviceStatus.providerId + serviceId = serviceStatus.externalId } else { log.verbose({ section: service.name, @@ -263,7 +263,7 @@ async function getServiceStatus({ ctx, service }: GetServiceStatusParams { const emoji = printEmoji("hourglass_flowing_sand", footerLog) - footerLog.setState(`\n${emoji}Processing...`) + footerLog.setState(`\n${emoji} Processing...`) }) garden.events.on("taskGraphComplete", () => { const emoji = printEmoji("clock2", footerLog) - footerLog.setState(`\n${emoji}${chalk.gray("Waiting for code changes...")}`) + footerLog.setState(`\n${emoji} ${chalk.gray("Waiting for code changes...")}`) }) } diff --git a/garden-service/src/proxy.ts b/garden-service/src/proxy.ts new file mode 100644 index 0000000000..4686d0ce59 --- /dev/null +++ b/garden-service/src/proxy.ts @@ -0,0 +1,254 @@ +/* + * Copyright (C) 2018 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { isEqual, invert } from "lodash" +import * as Bluebird from "bluebird" +import { createServer, Server, Socket } from "net" +const AsyncLock = require("async-lock") +import getPort = require("get-port") +import { Service, ServiceStatus, ForwardablePort } from "./types/service" +import { Garden } from "./garden" +import { registerCleanupFunction } from "./util/util" +import { LogEntry } from "./logger/log-entry" +import { GetPortForwardResult } from "./types/plugin/service/getPortForward" + +interface PortProxy { + key: string + localPort: number + localUrl: string + server: Server + service: Service + spec: ForwardablePort +} + +const activeProxies: { [key: string]: PortProxy } = {} + +registerCleanupFunction("kill-service-port-proxies", () => { + for (const proxy of Object.values(activeProxies)) { + stopPortProxy(proxy) + } +}) + +const portLock = new AsyncLock() + +async function startPortProxy(garden: Garden, log: LogEntry, service: Service, spec: ForwardablePort) { + const key = getPortKey(service, spec) + let proxy = activeProxies[key] + + if (!proxy) { + // Start new proxy + proxy = activeProxies[key] = await createProxy(garden, 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) + } + + return proxy +} + +// TODO: handle dead port forwards +async function createProxy(garden: Garden, log: LogEntry, service: Service, spec: ForwardablePort): Promise { + const actions = await garden.getActionHelper() + const key = getPortKey(service, spec) + let fwd: GetPortForwardResult + + const getPortForward = async () => { + if (fwd) { + return fwd + } + + await portLock.acquire(key, async () => { + if (fwd) { + return + } + log.debug(`Starting port forward to ${key}`) + + try { + fwd = await actions.getPortForward({ service, log, ...spec }) + } catch (err) { + log.error(`Error starting port forward to ${key}: ${err.message}`) + } + + log.debug(`Successfully started port forward to ${key}`) + }) + + return fwd + } + + const server = createServer((local) => { + let _remote: Socket + + const getRemote = async () => { + if (!_remote) { + const { hostname, port } = await getPortForward() + + log.debug(`Connecting to ${key} port forward at ${hostname}:${port}`) + + _remote = new Socket() + _remote.connect(port, hostname) + + _remote.on("data", (data) => { + if (!local.writable) { + _remote.end() + } + const flushed = local.write(data) + if (!flushed) { + _remote.pause() + } + }) + + _remote.on("drain", () => { + local.resume() + }) + + _remote.on("close", () => { + log.debug(`Connection from ${local.remoteAddress}:${local.remotePort} ended`) + local.end() + }) + + _remote.on("error", (err) => { + log.debug(`Remote socket error: ${err.message}`) + }) + } + return _remote + } + + local.on("connect", () => { + log.debug(`Connection from ${local.remoteAddress}:${local.remotePort}`) + // tslint:disable-next-line: no-floating-promises + getRemote() + }) + + const writeToRemote = (remote: Socket, data: Buffer) => { + if (!remote.writable) { + local.end() + } + const flushed = remote.write(data) + if (!flushed) { + local.pause() + } + } + + local.on("data", (data) => { + if (_remote) { + writeToRemote(_remote, data) + } else { + getRemote() + .then((remote) => { + remote && writeToRemote(remote, data) + }) + // Promises are appropriately handled in the getRemote function + .catch(() => { }) + } + }) + + local.on("drain", () => { + _remote.resume() + }) + + local.on("close", () => { + _remote.end() + }) + + local.on("error", (err) => { + log.debug(`Local socket error: ${err.message}`) + }) + }) + + const localPort = await getPort() + const host = `localhost:${localPort}` + // For convenience, we try to guess a protocol based on the target port, if no URL protocol is specified + const protocol = spec.urlProtocol || guessProtocol(spec) + const localUrl = protocol ? `${protocol.toLowerCase()}://${host}` : host + + log.debug(`Starting proxy to ${key} on port ${localPort}`) + server.listen(localPort) + + return { key, server, service, spec, localPort, localUrl } +} + +function stopPortProxy(proxy: PortProxy, log?: LogEntry) { + // TODO: call stopPortForward handler + log && log.debug(`Stopping port forward to ${proxy.key}`) + delete activeProxies[proxy.key] + proxy.server.close() +} + +export async function startPortProxies(garden: Garden, log: LogEntry, service: Service, status: ServiceStatus) { + return Bluebird.map(status.forwardablePorts || [], (spec) => { + return startPortProxy(garden, log, service, spec) + }) +} + +function getPortKey(service: Service, spec: ForwardablePort) { + return `${service.name}/${spec.targetHostname || ""}:${spec.targetPort}` +} + +const standardProtocolPorts = { + acap: 674, + afp: 548, + dict: 2628, + dns: 53, + ftp: 21, + git: 9418, + gopher: 70, + http: 80, + https: 443, + imap: 143, + ipp: 631, + ipps: 631, + irc: 194, + ircs: 6697, + ldap: 389, + ldaps: 636, + mms: 1755, + msrp: 2855, + mtqp: 1038, + nfs: 111, + nntp: 119, + nntps: 563, + pop: 110, + postgres: 5432, + prospero: 1525, + redis: 6379, + rsync: 873, + rtsp: 554, + rtsps: 322, + rtspu: 5005, + sftp: 22, + smb: 445, + snmp: 161, + ssh: 22, + svn: 3690, + telnet: 23, + ventrilo: 3784, + vnc: 5900, + wais: 210, + // "ws": 80, + // "wss": 443, +} + +const standardProtocolPortIndex = invert(standardProtocolPorts) + +function guessProtocol(spec: ForwardablePort) { + const port = spec.targetPort + let protocol = standardProtocolPortIndex[port] + + if (protocol) { + return protocol + } else if (port >= 8000 && port < 9000) { + // 8xxx ports are commonly HTTP + return "http" + } else if (spec.name && standardProtocolPorts[spec.name]) { + // If the port spec name is a known protocol we return that + return spec.name + } else { + return null + } +} diff --git a/garden-service/src/tasks/deploy.ts b/garden-service/src/tasks/deploy.ts index 1605428555..22d4766437 100644 --- a/garden-service/src/tasks/deploy.ts +++ b/garden-service/src/tasks/deploy.ts @@ -16,6 +16,7 @@ import { Garden } from "../garden" import { TaskTask } from "./task" import { BuildTask } from "./build" import { ConfigGraph } from "../config-graph" +import { startPortProxies } from "../proxy" export interface DeployTaskParams { garden: Garden @@ -159,6 +160,21 @@ export class DeployTask extends BaseTask { log.info(chalk.gray("→ Ingress: ") + chalk.underline.gray(getIngressUrl(ingress))) } + if (this.garden.persistent) { + const proxies = await startPortProxies(this.garden, log, this.service, status) + + for (const proxy of proxies) { + const targetHost = proxy.spec.targetHostname || this.service.name + + log.info(chalk.gray( + `→ Forward: ` + + chalk.underline(proxy.localUrl) + + ` → ${targetHost}:${proxy.spec.targetPort}` + + (proxy.spec.name ? ` (${proxy.spec.name})` : ""), + )) + } + } + return status } } diff --git a/garden-service/src/types/plugin/plugin.ts b/garden-service/src/types/plugin/plugin.ts index aeabf88931..0a66d4efab 100644 --- a/garden-service/src/types/plugin/plugin.ts +++ b/garden-service/src/types/plugin/plugin.ts @@ -41,6 +41,8 @@ import { mapValues } from "lodash" import { getDebugInfo, DebugInfo, GetDebugInfoParams } from "./provider/getDebugInfo" import { deline } from "../../util/string" import { pluginCommandSchema, PluginCommand } from "./command" +import { getPortForward, GetPortForwardParams, GetPortForwardResult } from "./service/getPortForward" +import { StopPortForwardParams, stopPortForward } from "./service/stopPortForward" export type ServiceActions = { [P in keyof ServiceActionParams]: (params: ServiceActionParams[P]) => ServiceActionOutputs[P] @@ -115,33 +117,39 @@ export const pluginActionDescriptions: { [P in PluginActionName]: PluginActionDe } export interface ServiceActionParams { - getServiceStatus: GetServiceStatusParams deployService: DeployServiceParams - hotReloadService: HotReloadServiceParams deleteService: DeleteServiceParams execInService: ExecInServiceParams + getPortForward: GetPortForwardParams getServiceLogs: GetServiceLogsParams + getServiceStatus: GetServiceStatusParams + hotReloadService: HotReloadServiceParams runService: RunServiceParams + stopPortForward: StopPortForwardParams } export interface ServiceActionOutputs { - getServiceStatus: Promise deployService: Promise - hotReloadService: Promise deleteService: Promise execInService: Promise + getPortForward: Promise getServiceLogs: Promise<{}> + getServiceStatus: Promise + hotReloadService: Promise runService: Promise + stopPortForward: Promise<{}> } export const serviceActionDescriptions: { [P in ServiceActionName]: PluginActionDescription } = { - getServiceStatus, deployService, - hotReloadService, deleteService, execInService, + getPortForward, getServiceLogs, + getServiceStatus, + hotReloadService, runService, + stopPortForward, } export interface TaskActionParams { diff --git a/garden-service/src/types/plugin/service/getPortForward.ts b/garden-service/src/types/plugin/service/getPortForward.ts new file mode 100644 index 0000000000..ea1a4d2bb1 --- /dev/null +++ b/garden-service/src/types/plugin/service/getPortForward.ts @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2018 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { PluginServiceActionParamsBase, serviceActionParamsSchema } from "../base" +import { dedent } from "../../../util/string" +import { Module } from "../../module" +import { ForwardablePort, forwardablePortKeys } from "../../service" +import { joi } from "../../../config/common" + +export type GetPortForwardParams = + PluginServiceActionParamsBase + & ForwardablePort + +export interface GetPortForwardResult { + hostname: string + port: number +} + +export const getPortForward = { + description: dedent` + Create a port forward tunnel to the specified service and port. When \`getServiceStatus\` returns one or more + \`forwardablePort\` specs, the Garden service creates an open port. When connections are made to that port, + this handler is called to create a tunnel, and the connection (and any subsequent connections) is forwarded to + the tunnel. + + The tunnel should be persistent. If the tunnel stops listening to connections, this handler will be called again. + + If there is a corresponding \`stopPortForward\` handler, it is called when cleaning up. + `, + paramsSchema: serviceActionParamsSchema + .keys(forwardablePortKeys), + resultSchema: joi.object() + .keys({ + hostname: joi.string() + .hostname() + .description("The hostname of the port tunnel.") + .example("localhost"), + port: joi.number() + .integer() + .description("The port of the tunnel.") + .example(12345), + }), +} diff --git a/garden-service/src/types/plugin/service/stopPortForward.ts b/garden-service/src/types/plugin/service/stopPortForward.ts new file mode 100644 index 0000000000..9c08c570ed --- /dev/null +++ b/garden-service/src/types/plugin/service/stopPortForward.ts @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2018 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { PluginServiceActionParamsBase, serviceActionParamsSchema } from "../base" +import { dedent } from "../../../util/string" +import { Module } from "../../module" +import { ForwardablePort, forwardablePortKeys } from "../../service" +import { joi } from "../../../config/common" + +export type StopPortForwardParams = + PluginServiceActionParamsBase + & ForwardablePort + +export const stopPortForward = { + description: dedent` + Close a port forward created by \`getPortForward\`. + `, + paramsSchema: serviceActionParamsSchema + .keys(forwardablePortKeys), + resultSchema: joi.object() + .keys({}), +} diff --git a/garden-service/src/types/service.ts b/garden-service/src/types/service.ts index 7e56bcaa55..abba8f18a4 100644 --- a/garden-service/src/types/service.ts +++ b/garden-service/src/types/service.ts @@ -7,7 +7,16 @@ */ import { getEnvVarName, uniqByName } from "../util/util" -import { PrimitiveMap, joiEnvVars, joiIdentifierMap, joiPrimitive, joiUserIdentifier, joi } from "../config/common" +import { + PrimitiveMap, + joiEnvVars, + joiIdentifierMap, + joiPrimitive, + joiUserIdentifier, + joi, + joiIdentifier, + joiArray, +} from "../config/common" import { Module, getModuleKey } from "./module" import { ServiceConfig, serviceConfigSchema } from "../config/service" import dedent = require("dedent") @@ -131,19 +140,48 @@ export const serviceIngressSchema = serviceIngressSpecSchema .unknown(true) .description("A description of a deployed service ingress.") -// TODO: revise this schema +export interface ForwardablePort { + name?: string + // TODO: support other protocols + protocol: "TCP" + targetHostname?: string + targetPort: number + urlProtocol?: string +} + +export const forwardablePortKeys = { + name: joiIdentifier() + .description("A descriptive name for the port. Should correspond to user-configured ports where applicable."), + protocol: joi.string() + .allow("TCP") + .default("TCP") + .description("The protocol of the port."), + targetHostname: joi.string() + .description("The target hostname of the service (only used for informational purposes)."), + targetPort: joi.number() + .integer() + .required() + .description("The target port on the service."), + urlProtocol: joi.string() + .description("The protocol to use for URLs pointing at the port. This can be any valid URI protocol."), +} + +const forwardablePortSchema = joi.object() + .keys(forwardablePortKeys) + export interface ServiceStatus { - providerId?: string - providerVersion?: string - version?: string - state?: ServiceState - runningReplicas?: number + createdAt?: string + detail?: any + externalId?: string + externalVersion?: string + forwardablePorts?: ForwardablePort[], ingresses?: ServiceIngress[], lastMessage?: string lastError?: string - createdAt?: string + runningReplicas?: number + state?: ServiceState updatedAt?: string - detail?: any + version?: string } export interface ServiceStatusMap { @@ -152,18 +190,17 @@ export interface ServiceStatusMap { export const serviceStatusSchema = joi.object() .keys({ - providerId: joi.string() + createdAt: joi.string() + .description("When the service was first deployed by the provider."), + detail: joi.object() + .meta({ extendable: true }) + .description("Additional detail, specific to the provider."), + externalId: joi.string() .description("The ID used for the service by the provider (if not the same as the service name)."), - providerVersion: joi.string() + externalVersion: joi.string() .description("The provider version of the deployed service (if different from the Garden module version."), - version: joi.string() - .description("The Garden module version of the deployed service."), - state: joi.string() - .only("ready", "deploying", "stopped", "unhealthy", "unknown", "outdated", "missing") - .default("unknown") - .description("The current deployment status of the service."), - runningReplicas: joi.number() - .description("How many replicas of the service are currently running."), + forwardablePorts: joiArray(forwardablePortSchema) + .description("A list of ports that can be forwarded to from the Garden agent by the provider."), ingresses: joi.array() .items(serviceIngressSchema) .description("List of currently deployed ingress endpoints for the service."), @@ -172,13 +209,16 @@ export const serviceStatusSchema = joi.object() .description("Latest status message of the service (if any)."), lastError: joi.string() .description("Latest error status message of the service (if any)."), - createdAt: joi.string() - .description("When the service was first deployed by the provider."), + runningReplicas: joi.number() + .description("How many replicas of the service are currently running."), + state: joi.string() + .only("ready", "deploying", "stopped", "unhealthy", "unknown", "outdated", "missing") + .default("unknown") + .description("The current deployment status of the service."), updatedAt: joi.string() .description("When the service was last updated by the provider."), - detail: joi.object() - .meta({ extendable: true }) - .description("Additional detail, specific to the provider."), + version: joi.string() + .description("The Garden module version of the deployed service."), }) export type RuntimeContext = { diff --git a/garden-service/test/unit/src/actions.ts b/garden-service/test/unit/src/actions.ts index d3dc858e7a..e225a93420 100644 --- a/garden-service/test/unit/src/actions.ts +++ b/garden-service/test/unit/src/actions.ts @@ -232,21 +232,21 @@ describe("ActionHelper", () => { describe("getServiceStatus", () => { it("should correctly call the corresponding plugin handler", async () => { const result = await actions.getServiceStatus({ log, service, runtimeContext, hotReload: false }) - expect(result).to.eql({ state: "ready" }) + expect(result).to.eql({ forwardablePorts: [], state: "ready" }) }) }) describe("deployService", () => { it("should correctly call the corresponding plugin handler", async () => { const result = await actions.deployService({ log, service, runtimeContext, force: true, hotReload: false }) - expect(result).to.eql({ state: "ready" }) + expect(result).to.eql({ forwardablePorts: [], state: "ready" }) }) }) describe("deleteService", () => { it("should correctly call the corresponding plugin handler", async () => { const result = await actions.deleteService({ log, service, runtimeContext }) - expect(result).to.eql({ state: "ready" }) + expect(result).to.eql({ forwardablePorts: [], state: "ready" }) }) }) @@ -559,6 +559,19 @@ const testPlugin: PluginFactory = async () => ({ } }, + getPortForward: async (params) => { + validate(params, moduleActionDescriptions.getPortForward.paramsSchema) + return { + hostname: "bla", + port: 123, + } + }, + + stopPortForward: async (params) => { + validate(params, moduleActionDescriptions.stopPortForward.paramsSchema) + return {} + }, + getTaskResult: async (params) => { validate(params, moduleActionDescriptions.getTaskResult.paramsSchema) const module = params.task.module diff --git a/garden-service/test/unit/src/commands/delete.ts b/garden-service/test/unit/src/commands/delete.ts index dcb4e6fe9d..e66e0c3204 100644 --- a/garden-service/test/unit/src/commands/delete.ts +++ b/garden-service/test/unit/src/commands/delete.ts @@ -121,10 +121,10 @@ describe("DeleteEnvironmentCommand", () => { expect(result!.environmentStatuses["test-plugin"]["ready"]).to.be.false expect(result!.serviceStatuses).to.eql({ - "service-a": { state: "missing" }, - "service-b": { state: "missing" }, - "service-c": { state: "missing" }, - "service-d": { state: "missing" }, + "service-a": { forwardablePorts: [], state: "missing" }, + "service-b": { forwardablePorts: [], state: "missing" }, + "service-c": { forwardablePorts: [], state: "missing" }, + "service-d": { forwardablePorts: [], state: "missing" }, }) expect(deletedServices.sort()).to.eql(["service-a", "service-b", "service-c", "service-d"]) }) @@ -176,7 +176,7 @@ describe("DeleteServiceCommand", () => { opts: withDefaultGlobalOpts({}), }) expect(result).to.eql({ - "service-a": { state: "unknown", ingresses: [] }, + "service-a": { forwardablePorts: [], state: "unknown", ingresses: [] }, }) }) @@ -193,8 +193,8 @@ describe("DeleteServiceCommand", () => { opts: withDefaultGlobalOpts({}), }) expect(result).to.eql({ - "service-a": { state: "unknown", ingresses: [] }, - "service-b": { state: "unknown", ingresses: [] }, + "service-a": { forwardablePorts: [], state: "unknown", ingresses: [] }, + "service-b": { forwardablePorts: [], state: "unknown", ingresses: [] }, }) }) }) diff --git a/garden-service/test/unit/src/commands/deploy.ts b/garden-service/test/unit/src/commands/deploy.ts index 1bad6dcde4..3429d62d95 100644 --- a/garden-service/test/unit/src/commands/deploy.ts +++ b/garden-service/test/unit/src/commands/deploy.ts @@ -112,10 +112,10 @@ describe("DeployCommand", () => { "build.module-c": {}, "task.task-a": taskResultA, "task.task-c": taskResultC, - "deploy.service-a": { version: "1", state: "ready" }, - "deploy.service-b": { version: "1", state: "ready" }, - "deploy.service-c": { version: "1", state: "ready" }, - "deploy.service-d": { version: "1", state: "ready" }, + "deploy.service-a": { forwardablePorts: [], version: "1", state: "ready" }, + "deploy.service-b": { forwardablePorts: [], version: "1", state: "ready" }, + "deploy.service-c": { forwardablePorts: [], version: "1", state: "ready" }, + "deploy.service-d": { forwardablePorts: [], version: "1", state: "ready" }, }) }) @@ -150,8 +150,8 @@ describe("DeployCommand", () => { "build.module-c": {}, "task.task-a": taskResultA, "task.task-c": taskResultC, - "deploy.service-a": { version: "1", state: "ready" }, - "deploy.service-b": { version: "1", state: "ready" }, + "deploy.service-a": { forwardablePorts: [], version: "1", state: "ready" }, + "deploy.service-b": { forwardablePorts: [], version: "1", state: "ready" }, }) }) })