Skip to content

Commit

Permalink
feat: automatic port forwarding for deployed services
Browse files Browse the repository at this point in the history
This adds a built-in TCP proxy for long-running Garden processes,
that immediately opens a port for each forwardable service port (as
specified by providers) and creates a tunnel on-demand when the port is
accessed.

This should work for any TCP port, and is implemented here for the
kubernetes provider, but the proxy itself could be implemented for other
providers through the fairly simple `getPortForward` handler interface.

Unlike kubefwd, for example, this implementation avoids any
need to mess with domain names or host files (and by extension running
as root) since a random free port is assigned directly on localhost.
The tunnels are only created when a connection is first made, and are
then kept alive while the Garden agent is running.

Closes #967
  • Loading branch information
edvald committed Aug 6, 2019
1 parent 068eb25 commit 459c97e
Show file tree
Hide file tree
Showing 33 changed files with 695 additions and 158 deletions.
10 changes: 10 additions & 0 deletions garden-service/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>)]: (...args: any[]) => Promise<any>
Expand Down Expand Up @@ -345,6 +347,14 @@ export class ActionHelper implements TypeGuard {
return this.callServiceHandler({ params, actionType: "runService" })
}

async getPortForward(params: ServiceActionHelperParams<GetPortForwardParams>) {
return this.callServiceHandler({ params, actionType: "getPortForward" })
}

async stopPortForward(params: ServiceActionHelperParams<StopPortForwardParams>) {
return this.callServiceHandler({ params, actionType: "stopPortForward" })
}

//endregion

//===========================================================================
Expand Down
4 changes: 3 additions & 1 deletion garden-service/src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,14 +283,16 @@ export class GardenCli {
let garden: Garden
let result: any

await command.prepare({
const { persistent } = await command.prepare({
log,
headerLog,
footerLog,
args: parsedArgs,
opts: parsedOpts,
})

contextOpts.persistent = persistent

do {
try {
garden = await Garden.factory(root, contextOpts)
Expand Down
8 changes: 7 additions & 1 deletion garden-service/src/commands/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ export interface CommandParams<T extends Parameters = {}, U extends Parameters =
garden: Garden
}

interface PrepareOutput {
// Commands should set this to true if the command is long-running
persistent: boolean
}

export abstract class Command<T extends Parameters = {}, U extends Parameters = {}> {
abstract name: string
abstract help: string
Expand Down Expand Up @@ -302,7 +307,8 @@ export abstract class Command<T extends Parameters = {}, U extends Parameters =
* Called by the CLI before the command's action is run, but is not called again
* if the command restarts. Useful for commands in watch mode.
*/
async prepare(_: PrepareParams<T, U>) {
async prepare(_: PrepareParams<T, U>): Promise<PrepareOutput> {
return { persistent: false }
}

// Note: Due to a current TS limitation (apparently covered by https://github.com/Microsoft/TypeScript/issues/7011),
Expand Down
6 changes: 5 additions & 1 deletion garden-service/src/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,13 @@ export class BuildCommand extends Command<Args, Opts> {
async prepare({ headerLog, footerLog, opts }: PrepareParams<Args, Opts>) {
printHeader(headerLog, "Build", "hammer")

if (!!opts.watch) {
const persistent = !!opts.watch

if (persistent) {
this.server = await startServer(footerLog)
}

return { persistent }
}

async action(
Expand Down
6 changes: 5 additions & 1 deletion garden-service/src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,13 @@ export class DeployCommand extends Command<Args, Opts> {
async prepare({ headerLog, footerLog, opts }: PrepareParams<Args, Opts>) {
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<Args, Opts>): Promise<CommandResult<TaskResults>> {
Expand Down
2 changes: 2 additions & 0 deletions garden-service/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ export class DevCommand extends Command<Args, Opts> {
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<Args, Opts>): Promise<CommandResult> {
Expand Down
1 change: 1 addition & 0 deletions garden-service/src/commands/get/get-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export class GetTasksCommand extends Command<Args> {

async prepare({ headerLog }: PrepareParams<Args>) {
printHeader(headerLog, "Tasks", "open_book")
return { persistent: false }
}

async action({ args, garden, log }: CommandParams<Args>): Promise<CommandResult> {
Expand Down
1 change: 1 addition & 0 deletions garden-service/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export class ServeCommand extends Command<Args, Opts> {

async prepare({ footerLog, opts }: PrepareParams<Args, Opts>) {
this.server = await startServer(footerLog, opts.port)
return { persistent: true }
}

async action({ garden }: CommandParams<Args, Opts>): Promise<CommandResult<{}>> {
Expand Down
6 changes: 5 additions & 1 deletion garden-service/src/commands/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,13 @@ export class TestCommand extends Command<Args, Opts> {
async prepare({ headerLog, footerLog, opts }: PrepareParams<Args, Opts>) {
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<Args, Opts>): Promise<CommandResult<TaskResults>> {
Expand Down
3 changes: 3 additions & 0 deletions garden-service/src/garden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export interface GardenOpts {
config?: ProjectConfig,
gardenDirPath?: string,
environmentName?: string,
persistent?: boolean,
log?: LogEntry,
plugins?: Plugins,
}
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions garden-service/src/plugins/google/google-cloud-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions garden-service/src/plugins/kubernetes/container/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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"
Expand Down Expand Up @@ -130,7 +131,7 @@ const remoteBuild: BuildHandler = async (params) => {
ctx,
log,
namespace: systemNamespace,
targetDeployment: `Deployment/${buildSyncDeploymentName}`,
targetResource: `Deployment/${buildSyncDeploymentName}`,
port: RSYNC_PORT,
})

Expand Down
2 changes: 2 additions & 0 deletions garden-service/src/plugins/kubernetes/container/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ContainerModule>) {
params.moduleConfig = await configureContainerModule(params)
Expand All @@ -41,6 +42,7 @@ export const containerHandlers = {
deleteService,
execInService,
getBuildStatus: k8sGetContainerBuildStatus,
getPortForward: getPortForwardHandler,
getServiceLogs,
getServiceStatus: getContainerServiceStatus,
getTestResult,
Expand Down
15 changes: 14 additions & 1 deletion garden-service/src/plugins/kubernetes/container/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions garden-service/src/plugins/kubernetes/container/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
})
}
Expand Down
9 changes: 8 additions & 1 deletion garden-service/src/plugins/kubernetes/helm/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HelmModule>,
Expand Down Expand Up @@ -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<ServiceStatus> {
Expand Down
2 changes: 2 additions & 0 deletions garden-service/src/plugins/kubernetes/helm/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -44,6 +45,7 @@ export const helmHandlers: Partial<ModuleAndRuntimeActions<HelmModule>> = {
deleteService,
deployService,
describeType,
getPortForward: getPortForwardHandler,
getServiceLogs,
getServiceStatus,
getTestResult,
Expand Down
6 changes: 6 additions & 0 deletions garden-service/src/plugins/kubernetes/helm/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 5 additions & 5 deletions garden-service/src/plugins/kubernetes/hot-reload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -241,11 +241,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)
Expand All @@ -262,8 +262,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@ 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<ModuleAndRuntimeActions<KubernetesModule>> = {
build,
configure: configureKubernetesModule,
deleteService,
deployService,
describeType,
getPortForward: getPortForwardHandler,
getServiceLogs,
getServiceStatus,
}
Expand Down Expand Up @@ -62,7 +64,10 @@ async function getServiceStatus(

const { state, remoteObjects } = await compareDeployedObjects(k8sCtx, api, namespace, manifests, log, false)

const forwardablePorts = getForwardablePorts(remoteObjects)

return {
forwardablePorts,
state,
version: state === "ready" ? module.version.versionString : undefined,
detail: { remoteObjects },
Expand Down
Loading

0 comments on commit 459c97e

Please sign in to comment.