From 806d8c6716ff16c59f55162553eda64b85f4a4ad Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Fri, 10 Apr 2020 11:05:37 +0300 Subject: [PATCH] Initial port-forward implementation for services (#231) Signed-off-by: Jari Kolehmainen --- dashboard/client/api/endpoints/service.api.ts | 35 +++++- .../+network-services/service-details.scss | 3 +- .../+network-services/service-details.tsx | 7 +- .../+network-services/service-ports.scss | 24 ++++ .../+network-services/service-ports.tsx | 54 +++++++++ src/main/router.ts | 4 + src/main/routes/port-forward.ts | 107 ++++++++++++++++++ 7 files changed, 224 insertions(+), 10 deletions(-) create mode 100644 dashboard/client/components/+network-services/service-ports.scss create mode 100644 dashboard/client/components/+network-services/service-ports.tsx create mode 100644 src/main/routes/port-forward.ts diff --git a/dashboard/client/api/endpoints/service.api.ts b/dashboard/client/api/endpoints/service.api.ts index a4b088dff7..c3364d970b 100644 --- a/dashboard/client/api/endpoints/service.api.ts +++ b/dashboard/client/api/endpoints/service.api.ts @@ -2,6 +2,33 @@ import { autobind } from "../../utils"; import { KubeObject } from "../kube-object"; import { KubeApi } from "../kube-api"; +export interface IServicePort { + name?: string; + protocol: string; + port: number; + targetPort: number; +} + +export class ServicePort implements IServicePort { + name?: string; + protocol: string; + port: number; + targetPort: number; + nodePort?: number; + + constructor(data: IServicePort) { + Object.assign(this, data) + } + + toString() { + if (this.nodePort) { + return `${this.port}:${this.nodePort}/${this.protocol}`; + } else { + return `${this.port}${this.port === this.targetPort ? "" : ":" + this.targetPort}/${this.protocol}`; + } + } +} + @autobind() export class Service extends KubeObject { static kind = "Service" @@ -13,7 +40,7 @@ export class Service extends KubeObject { loadBalancerIP?: string; sessionAffinity: string; selector: { [key: string]: string }; - ports: { name?: string; protocol: string; port: number; targetPort: number }[]; + ports: ServicePort[]; externalIPs?: string[]; // https://kubernetes.io/docs/concepts/services-networking/service/#external-ips } @@ -47,11 +74,9 @@ export class Service extends KubeObject { return Object.entries(this.spec.selector).map(val => val.join("=")); } - getPorts(): string[] { + getPorts(): ServicePort[] { const ports = this.spec.ports || []; - return ports.map(({ port, protocol, targetPort }) => { - return `${port}${port === targetPort ? "" : ":" + targetPort}/${protocol}` - }) + return ports.map(p => new ServicePort(p)); } getLoadBalancer() { diff --git a/dashboard/client/components/+network-services/service-details.scss b/dashboard/client/components/+network-services/service-details.scss index be5f2de878..661b903955 100644 --- a/dashboard/client/components/+network-services/service-details.scss +++ b/dashboard/client/components/+network-services/service-details.scss @@ -1,3 +1,2 @@ .ServicesDetails { - -} \ No newline at end of file +} diff --git a/dashboard/client/components/+network-services/service-details.tsx b/dashboard/client/components/+network-services/service-details.tsx index 3ba98116a8..84d15f9dfd 100644 --- a/dashboard/client/components/+network-services/service-details.tsx +++ b/dashboard/client/components/+network-services/service-details.tsx @@ -11,6 +11,7 @@ import { Service, serviceApi } from "../../api/endpoints"; import { _i18n } from "../../i18n"; import { apiManager } from "../../api/api-manager"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; +import { ServicePorts } from "./service-ports"; interface Props extends KubeObjectDetailsProps { } @@ -49,8 +50,8 @@ export class ServiceDetails extends React.Component { )} - Ports} labelsOnly> - {service.getPorts().map(port => )} + Ports}> + {spec.type === "LoadBalancer" && spec.loadBalancerIP && ( @@ -67,4 +68,4 @@ export class ServiceDetails extends React.Component { apiManager.registerViews(serviceApi, { Details: ServiceDetails, -}) \ No newline at end of file +}) diff --git a/dashboard/client/components/+network-services/service-ports.scss b/dashboard/client/components/+network-services/service-ports.scss new file mode 100644 index 0000000000..5a683af86c --- /dev/null +++ b/dashboard/client/components/+network-services/service-ports.scss @@ -0,0 +1,24 @@ +.ServicePorts { + &.waiting { + opacity: 0.5; + pointer-events: none; + } + + p { + &:not(:last-child) { + margin-bottom: $margin; + } + + span { + cursor: pointer; + color: $primary; + text-decoration: underline; + } + } + + .Spinner { + --spinner-size: #{$unit * 2}; + margin-left: $margin; + position: absolute; + } +} diff --git a/dashboard/client/components/+network-services/service-ports.tsx b/dashboard/client/components/+network-services/service-ports.tsx new file mode 100644 index 0000000000..3335be6907 --- /dev/null +++ b/dashboard/client/components/+network-services/service-ports.tsx @@ -0,0 +1,54 @@ +import "./service-ports.scss" + +import React from "react"; +import { observer } from "mobx-react"; +import { t } from "@lingui/macro"; +import { Service, ServicePort } from "../../api/endpoints"; +import { _i18n } from "../../i18n"; +import { apiBase } from "../../api" +import { observable } from "mobx"; +import { cssNames } from "../../utils"; +import { Notifications } from "../notifications"; +import { Spinner } from "../spinner" + +interface Props { + service: Service; +} + +@observer +export class ServicePorts extends React.Component { + @observable waiting = false; + + async portForward(port: ServicePort) { + const { service } = this.props; + this.waiting = true; + apiBase.post(`/services/${service.getNs()}/${service.getName()}/port-forward/${port.port}`, {}) + .catch(error => { + Notifications.error(error); + }) + .finally(() => { + this.waiting = false; + }); + } + + render() { + const { service } = this.props; + return ( +
+ { + service.getPorts().map((port) => { + return( +

+ this.portForward(port) }> + {port.toString()} + {this.waiting && ( + + )} + +

+ ); + })} +
+ ); + } +} diff --git a/src/main/router.ts b/src/main/router.ts index d7187e9f9e..e1297a5d33 100644 --- a/src/main/router.ts +++ b/src/main/router.ts @@ -7,6 +7,7 @@ import { resourceApplierApi } from "./resource-applier-api" import { kubeconfigRoute } from "./routes/kubeconfig" import { metricsRoute } from "./routes/metrics" import { watchRoute } from "./routes/watch" +import { portForwardRoute } from "./routes/port-forward" import { readFile } from "fs" // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -118,6 +119,9 @@ export class Router { // Metrics API this.router.add({ method: 'post', path: '/api/metrics' }, metricsRoute.routeMetrics.bind(metricsRoute)) + // Port-forward API + this.router.add({ method: 'post', path: '/api/services/{namespace}/{service}/port-forward/{port}' }, portForwardRoute.routeServicePortForward.bind(portForwardRoute)) + // Helm API this.router.add({ method: 'get', path: '/api-helm/v2/charts' }, helmApi.listCharts.bind(helmApi)) this.router.add({ method: 'get', path: '/api-helm/v2/charts/{repo}/{chart}' }, helmApi.getChart.bind(helmApi)) diff --git a/src/main/routes/port-forward.ts b/src/main/routes/port-forward.ts new file mode 100644 index 0000000000..f79cc9228b --- /dev/null +++ b/src/main/routes/port-forward.ts @@ -0,0 +1,107 @@ +import { LensApiRequest } from "../router" +import { LensApi } from "../lens-api" +import { spawn, ChildProcessWithoutNullStreams } from "child_process" +import { bundledKubectl } from "../kubectl" +import { getFreePort } from "../port" +import { shell } from "electron" +import * as tcpPortUsed from "tcp-port-used" +import logger from "../logger" + +class PortForward { + public static portForwards: PortForward[] = [] + + static getPortforward(forward: {clusterId: string; kind: string; name: string; namespace: string; port: string}) { + return PortForward.portForwards.find((pf) => { + return ( + pf.clusterId == forward.clusterId && + pf.kind == "service" && + pf.name == forward.name && + pf.namespace == forward.namespace && + pf.port == forward.port + ) + }) + } + + public clusterId: string + public process: ChildProcessWithoutNullStreams + public kubeConfig: string + public kind: string + public namespace: string + public name: string + public port: string + public localPort: number + + constructor(obj: any) { + Object.assign(this, obj) + } + + public async start() { + this.localPort = await getFreePort(8000, 9999) + const kubectlBin = await bundledKubectl.kubectlPath() + const args = [ + "--kubeconfig", this.kubeConfig, + "port-forward", + "-n", this.namespace, + `service/${this.name}`, + `${this.localPort}:${this.port}` + ] + + this.process = spawn(kubectlBin, args, { + env: process.env + }) + PortForward.portForwards.push(this) + this.process.on("exit", () => { + const index = PortForward.portForwards.indexOf(this) + if (index > -1) { + PortForward.portForwards.splice(index, 1) + } + }) + try { + await tcpPortUsed.waitUntilUsed(this.localPort, 500, 3000) + return true + } catch (error) { + this.process.kill() + return false + } + } + + public open() { + shell.openExternal(`http://localhost:${this.localPort}`) + } +} + +class PortForwardRoute extends LensApi { + + public async routeServicePortForward(request: LensApiRequest) { + const { params, response, cluster} = request + + let portForward = PortForward.getPortforward({ + clusterId: cluster.id, kind: "service", name: params.service, + namespace: params.namespace, port: params.port + }) + if (!portForward) { + logger.info(`Creating a new port-forward ${params.namespace}/${params.service}:${params.port}`) + portForward = new PortForward({ + clusterId: cluster.id, + kind: "service", + namespace: params.namespace, + name: params.service, + port: params.port, + kubeConfig: cluster.kubeconfigPath() + }) + const started = await portForward.start() + if (!started) { + this.respondJson(response, { + message: "Failed to open port-forward" + }, 400) + return + } + } + + portForward.open() + + this.respondJson(response, {}) + } +} + +export const portForwardRoute = new PortForwardRoute()