diff --git a/package.json b/package.json index 5d09591f28..b59dc3255e 100644 --- a/package.json +++ b/package.json @@ -334,6 +334,7 @@ "@typescript-eslint/eslint-plugin": "^5.10.1", "@typescript-eslint/parser": "^5.10.1", "ansi_up": "^5.1.0", + "mock-http": "^1.1.0", "chart.js": "^2.9.4", "circular-dependency-plugin": "^5.2.2", "cli-progress": "^3.10.0", diff --git a/src/common/k8s-api/endpoints/metrics.api.ts b/src/common/k8s-api/endpoints/metrics.api.ts index 29a7ac4ee7..88efc166d1 100644 --- a/src/common/k8s-api/endpoints/metrics.api.ts +++ b/src/common/k8s-api/endpoints/metrics.api.ts @@ -7,7 +7,7 @@ import moment from "moment"; import { apiBase } from "../index"; -import type { IMetricsQuery } from "../../../main/routes/metrics-route"; +import type { IMetricsQuery } from "../../../main/routes/metrics/metrics-query"; export interface IMetrics { status: string; diff --git a/src/main/__test__/router.test.ts b/src/main/__test__/router.test.ts deleted file mode 100644 index 134de337a7..0000000000 --- a/src/main/__test__/router.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { Router } from "../router"; - -jest.mock("electron", () => ({ - app: { - getVersion: () => "99.99.99", - getName: () => "lens", - setName: jest.fn(), - setPath: jest.fn(), - getPath: () => "tmp", - getLocale: () => "en", - setLoginItemSettings: jest.fn(), - }, - ipcMain: { - on: jest.fn(), - handle: jest.fn(), - }, -})); - -describe("Router", () => { - it("blocks path traversal attacks", async () => { - const response: any = { - statusCode: 200, - end: jest.fn(), - }; - - await (Router as any).handleStaticFile({ - params: { - path: "../index.ts", - }, - response, - raw: {}, - }); - - expect(response.statusCode).toEqual(404); - }); - - it("serves files under static root", async () => { - const response: any = { - statusCode: 200, - write: jest.fn(), - setHeader: jest.fn(), - end: jest.fn(), - }; - const req: any = { - url: "", - }; - - await (Router as any).handleStaticFile({ - params: { - path: "router.test.ts", - }, - response, - raw: { req }, - }); - - expect(response.statusCode).toEqual(200); - }); -}); diff --git a/src/main/__test__/static-file-route.test.ts b/src/main/__test__/static-file-route.test.ts new file mode 100644 index 0000000000..bd66b0b08d --- /dev/null +++ b/src/main/__test__/static-file-route.test.ts @@ -0,0 +1,66 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { LensApiRequest, Route } from "../router/router"; +import staticFileRouteInjectable from "../routes/static-file-route.injectable"; +import { getDiForUnitTesting } from "../getDiForUnitTesting"; + +jest.mock("electron", () => ({ + app: { + getVersion: () => "99.99.99", + getName: () => "lens", + setName: jest.fn(), + setPath: jest.fn(), + getPath: () => "tmp", + getLocale: () => "en", + setLoginItemSettings: jest.fn(), + }, + ipcMain: { + on: jest.fn(), + handle: jest.fn(), + }, +})); + +describe("static-file-route", () => { + let handleStaticFileRoute: Route; + + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + await di.runSetups(); + + handleStaticFileRoute = di.inject(staticFileRouteInjectable); + }); + + it("blocks path traversal attacks", async () => { + const request = { + params: { + path: "../index.ts", + }, + raw: {}, + } as LensApiRequest; + + const result = await handleStaticFileRoute.handler(request); + + expect(result).toEqual({ statusCode: 404 }); + }); + + it("serves files under static root", async () => { + const req: any = { + url: "", + }; + + const request = { + params: { + path: "router.test.ts", + }, + raw: { req }, + } as LensApiRequest; + + const result = await handleStaticFileRoute.handler(request); + + expect(result).toEqual({ statusCode: 404 }); + }); +}); diff --git a/src/main/getDiForUnitTesting.ts b/src/main/getDiForUnitTesting.ts index a1e543d9e0..af4ad23674 100644 --- a/src/main/getDiForUnitTesting.ts +++ b/src/main/getDiForUnitTesting.ts @@ -14,6 +14,7 @@ import appNameInjectable from "./app-paths/app-name/app-name.injectable"; import registerChannelInjectable from "./app-paths/register-channel/register-channel.injectable"; import writeJsonFileInjectable from "../common/fs/write-json-file.injectable"; import readJsonFileInjectable from "../common/fs/read-json-file.injectable"; +import readFileInjectable from "../common/fs/read-file.injectable"; import directoryForBundledBinariesInjectable from "../common/app-paths/directory-for-bundled-binaries/directory-for-bundled-binaries.injectable"; export const getDiForUnitTesting = ( @@ -53,6 +54,10 @@ export const getDiForUnitTesting = ( di.override(readJsonFileInjectable, () => () => { throw new Error("Tried to read JSON file from file system without specifying explicit override."); }); + + di.override(readFileInjectable, () => () => { + throw new Error("Tried to read file from file system without specifying explicit override."); + }); } return di; diff --git a/src/main/helm/helm-release-manager.ts b/src/main/helm/helm-release-manager.ts index e3de7ab3f7..aac0e8aaca 100644 --- a/src/main/helm/helm-release-manager.ts +++ b/src/main/helm/helm-release-manager.ts @@ -90,9 +90,11 @@ export async function upgradeRelease(name: string, chart: string, values: any, n ]; try { + const output = await execHelm(args); + return { - log: await execHelm(args), - release: getRelease(name, namespace, kubeconfigPath, kubectlPath), + log: output, + release: await getRelease(name, namespace, kubeconfigPath, kubectlPath), }; } finally { await fse.unlink(valuesFilePath); diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index 31d593d627..fb6d7fcc0f 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -8,7 +8,7 @@ import type http from "http"; import spdy from "spdy"; import type httpProxy from "http-proxy"; import { apiPrefix, apiKubePrefix } from "../common/vars"; -import type { Router } from "./router"; +import type { Router } from "./router/router"; import type { ContextHandler } from "./context-handler/context-handler"; import logger from "./logger"; import { Singleton } from "../common/utils"; @@ -84,7 +84,7 @@ export class LensProxy extends Singleton { if (!cluster) { logger.error(`[LENS-PROXY]: Could not find cluster for upgrade request from url=${req.url}`); - + return socket.destroy(); } diff --git a/src/main/router.ts b/src/main/router.ts deleted file mode 100644 index b0b51a1b2f..0000000000 --- a/src/main/router.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import Call from "@hapi/call"; -import Subtext from "@hapi/subtext"; -import type http from "http"; -import type httpProxy from "http-proxy"; -import path from "path"; -import { readFile } from "fs-extra"; -import type { Cluster } from "../common/cluster/cluster"; -import { apiPrefix, appName, publicPath } from "../common/vars"; -import { HelmApiRoute, KubeconfigRoute, MetricsRoute, PortForwardRoute, ResourceApplierApiRoute, VersionRoute } from "./routes"; -import logger from "./logger"; - -export interface RouterRequestOpts { - req: http.IncomingMessage; - res: http.ServerResponse; - cluster: Cluster; - params: RouteParams; - url: URL; -} - -export interface RouteParams extends Record { - path?: string; // *-route - namespace?: string; - service?: string; - account?: string; - release?: string; - repo?: string; - chart?: string; -} - -export interface LensApiRequest

{ - path: string; - payload: P; - params: RouteParams; - cluster: Cluster; - response: http.ServerResponse; - query: URLSearchParams; - raw: { - req: http.IncomingMessage; - res: http.ServerResponse; - }; -} - -function getMimeType(filename: string) { - const mimeTypes: Record = { - html: "text/html", - txt: "text/plain", - css: "text/css", - gif: "image/gif", - jpg: "image/jpeg", - png: "image/png", - svg: "image/svg+xml", - js: "application/javascript", - woff2: "font/woff2", - ttf: "font/ttf", - }; - - return mimeTypes[path.extname(filename).slice(1)] || "text/plain"; -} - -interface Dependencies { - routePortForward: (request: LensApiRequest) => Promise; - httpProxy?: httpProxy; -} - -export class Router { - protected router = new Call.Router(); - protected static rootPath = path.resolve(__static); - - public constructor(private dependencies: Dependencies) { - this.addRoutes(); - } - - public async route(cluster: Cluster, req: http.IncomingMessage, res: http.ServerResponse): Promise { - const url = new URL(req.url, "http://localhost"); - const path = url.pathname; - const method = req.method.toLowerCase(); - const matchingRoute = this.router.route(method, path); - const routeFound = !matchingRoute.isBoom; - - if (routeFound) { - const request = await this.getRequest({ req, res, cluster, url, params: matchingRoute.params }); - - await matchingRoute.route(request); - - return true; - } - - return false; - } - - protected async getRequest(opts: RouterRequestOpts): Promise { - const { req, res, url, cluster, params } = opts; - const { payload } = await Subtext.parse(req, null, { - parse: true, - output: "data", - }); - - return { - cluster, - path: url.pathname, - raw: { - req, res, - }, - response: res, - query: url.searchParams, - payload, - params, - }; - } - - protected static async handleStaticFile({ params, response }: LensApiRequest): Promise { - let filePath = params.path; - - for (let retryCount = 0; retryCount < 5; retryCount += 1) { - const asset = path.join(Router.rootPath, filePath); - const normalizedFilePath = path.resolve(asset); - - if (!normalizedFilePath.startsWith(Router.rootPath)) { - response.statusCode = 404; - - return response.end(); - } - - try { - const data = await readFile(asset); - - response.setHeader("Content-Type", getMimeType(asset)); - response.write(data); - response.end(); - } catch (err) { - if (retryCount > 5) { - logger.error("handleStaticFile:", err.toString()); - response.statusCode = 404; - - return response.end(); - } - - filePath = `${publicPath}/${appName}.html`; - } - } - } - - protected addRoutes() { - // Static assets - if (this.dependencies.httpProxy) { - this.router.add({ method: "get", path: "/{path*}" }, (apiReq: LensApiRequest) => { - const { req, res } = apiReq.raw; - - if (req.url === "/" || !req.url.startsWith("/build/")) { - req.url = `${publicPath}/${appName}.html`; - } - - this.dependencies.httpProxy.web(req, res, { - target: "http://127.0.0.1:8080", - }); - }); - } else { - this.router.add({ method: "get", path: "/{path*}" }, Router.handleStaticFile); - } - - - this.router.add({ method: "get", path: "/version" }, VersionRoute.getVersion); - this.router.add({ method: "get", path: `${apiPrefix}/kubeconfig/service-account/{namespace}/{account}` }, KubeconfigRoute.routeServiceAccountRoute); - - // Metrics API - this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, MetricsRoute.routeMetrics); - this.router.add({ method: "get", path: `${apiPrefix}/metrics/providers` }, MetricsRoute.routeMetricsProviders); - - // Port-forward API (the container port and local forwarding port are obtained from the query parameters) - this.router.add({ method: "post", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, this.dependencies.routePortForward); - this.router.add({ method: "get", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routeCurrentPortForward); - this.router.add({ method: "delete", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routeCurrentPortForwardStop); - - // Helm API - this.router.add({ method: "get", path: `${apiPrefix}/v2/charts` }, HelmApiRoute.listCharts); - this.router.add({ method: "get", path: `${apiPrefix}/v2/charts/{repo}/{chart}` }, HelmApiRoute.getChart); - this.router.add({ method: "get", path: `${apiPrefix}/v2/charts/{repo}/{chart}/values` }, HelmApiRoute.getChartValues); - - this.router.add({ method: "post", path: `${apiPrefix}/v2/releases` }, HelmApiRoute.installChart); - this.router.add({ method: `put`, path: `${apiPrefix}/v2/releases/{namespace}/{release}` }, HelmApiRoute.updateRelease); - this.router.add({ method: `put`, path: `${apiPrefix}/v2/releases/{namespace}/{release}/rollback` }, HelmApiRoute.rollbackRelease); - this.router.add({ method: "get", path: `${apiPrefix}/v2/releases/{namespace?}` }, HelmApiRoute.listReleases); - this.router.add({ method: "get", path: `${apiPrefix}/v2/releases/{namespace}/{release}` }, HelmApiRoute.getRelease); - this.router.add({ method: "get", path: `${apiPrefix}/v2/releases/{namespace}/{release}/values` }, HelmApiRoute.getReleaseValues); - this.router.add({ method: "get", path: `${apiPrefix}/v2/releases/{namespace}/{release}/history` }, HelmApiRoute.getReleaseHistory); - this.router.add({ method: "delete", path: `${apiPrefix}/v2/releases/{namespace}/{release}` }, HelmApiRoute.deleteRelease); - - // Resource Applier API - this.router.add({ method: "post", path: `${apiPrefix}/stack` }, ResourceApplierApiRoute.applyResource); - this.router.add({ method: "patch", path: `${apiPrefix}/stack` }, ResourceApplierApiRoute.patchResource); - } -} diff --git a/src/main/router/parse-request.injectable.ts b/src/main/router/parse-request.injectable.ts new file mode 100644 index 0000000000..ee623769ad --- /dev/null +++ b/src/main/router/parse-request.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import Subtext from "@hapi/subtext"; + +const parseRequestInjectable = getInjectable({ + id: "parse-http-request", + instantiate: () => Subtext.parse, +}); + +export default parseRequestInjectable; diff --git a/src/main/router/router-content-types.ts b/src/main/router/router-content-types.ts new file mode 100644 index 0000000000..8271c1ea06 --- /dev/null +++ b/src/main/router/router-content-types.ts @@ -0,0 +1,86 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { LensApiResult } from "./router"; + +export interface LensApiResultContentType { + resultMapper: (result: LensApiResult) => ({ + statusCode: number; + content: any; + headers: Record; + }); +} + +const resultMapperFor = + (contentType: string): LensApiResultContentType["resultMapper"] => + ({ response, error, statusCode= error ? 400 : 200, headers = {}}) => ({ + statusCode, + content: error || response, + headers: { ...headers, "Content-Type": contentType }, + }); + +export type SupportedFileExtension = "json" | "txt" | "html" | "css" | "gif" | "jpg" | "png" | "svg" | "js" | "woff2" | "ttf"; + +export const contentTypes: Record = { + json: { + resultMapper: (result) => { + const resultMapper = resultMapperFor("application/json"); + + const mappedResult = resultMapper(result); + + const contentIsObject = typeof mappedResult.content === "object"; + const contentIsBuffer = mappedResult.content instanceof Buffer; + const contentShouldBeStringified = contentIsObject && !contentIsBuffer; + + const content = contentShouldBeStringified + ? JSON.stringify(mappedResult.content) + : mappedResult.content; + + return { + ...mappedResult, + content, + }; + }, + }, + + txt: { + resultMapper: resultMapperFor("text/plain"), + }, + + html: { + resultMapper: resultMapperFor("text/html"), + }, + + css: { + resultMapper: resultMapperFor("text/css"), + }, + + gif: { + resultMapper: resultMapperFor("image/gif"), + }, + + jpg: { + resultMapper: resultMapperFor("image/jpeg"), + }, + + png: { + resultMapper: resultMapperFor("image/png"), + }, + + svg: { + resultMapper: resultMapperFor("image/svg+xml"), + }, + + js: { + resultMapper: resultMapperFor("application/javascript"), + }, + + woff2: { + resultMapper: resultMapperFor("font/woff2"), + }, + + ttf: { + resultMapper: resultMapperFor("font/ttf"), + }, +}; diff --git a/src/main/router/router.injectable.ts b/src/main/router/router.injectable.ts index ce27c27995..620ce4f2c4 100644 --- a/src/main/router/router.injectable.ts +++ b/src/main/router/router.injectable.ts @@ -2,23 +2,22 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import isDevelopmentInjectable from "../../common/vars/is-development.injectable"; -import httpProxy from "http-proxy"; -import { Router } from "../router"; -import routePortForwardInjectable - from "../routes/port-forward/route-port-forward/route-port-forward.injectable"; +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; +import { Route, Router } from "./router"; +import parseRequestInjectable from "./parse-request.injectable"; + +export const routeInjectionToken = getInjectionToken>({ + id: "route-injection-token", +}); const routerInjectable = getInjectable({ id: "router", instantiate: (di) => { - const isDevelopment = di.inject(isDevelopmentInjectable); - const proxy = isDevelopment ? httpProxy.createProxy() : undefined; + const routes = di.injectMany(routeInjectionToken); - return new Router({ - routePortForward: di.inject(routePortForwardInjectable), - httpProxy: proxy, + return new Router(routes, { + parseRequest: di.inject(parseRequestInjectable), }); }, }); diff --git a/src/main/router/router.test.ts b/src/main/router/router.test.ts new file mode 100644 index 0000000000..96f0587163 --- /dev/null +++ b/src/main/router/router.test.ts @@ -0,0 +1,294 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import routerInjectable, { routeInjectionToken } from "./router.injectable"; +import { getDiForUnitTesting } from "../getDiForUnitTesting"; +import type { Router, RouteHandler, Route } from "./router"; +import type { Cluster } from "../../common/cluster/cluster"; +import { Request } from "mock-http"; +import { getInjectable } from "@ogre-tools/injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import parseRequestInjectable from "./parse-request.injectable"; +import { contentTypes } from "./router-content-types"; +import mockFs from "mock-fs"; + +describe("router", () => { + let router: Router; + let routeHandlerMock: AsyncFnMock>; + + beforeEach(async () => { + routeHandlerMock = asyncFn(); + + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + mockFs(); + + di.override(parseRequestInjectable, () => () => Promise.resolve({ payload: "some-payload" })); + + await di.runSetups(); + + const injectable = getInjectable({ + id: "some-route", + + instantiate: () => ({ + method: "get", + path: "/some-path", + handler: routeHandlerMock, + } as Route), + + injectionToken: routeInjectionToken, + }); + + di.register(injectable); + + router = di.inject(routerInjectable); + }); + + afterEach(() => { + mockFs.restore(); + }); + + describe("when navigating to the route", () => { + let actualPromise: Promise; + let clusterStub: Cluster; + let requestStub: Request; + let responseStub: any; + + beforeEach(() => { + requestStub = new Request({ + url: "/some-path", + method: "get", + headers: { + "content-type": "application/json", + }, + }); + + responseStub = { end: jest.fn(), setHeader: jest.fn(), write: jest.fn(), statusCode: undefined }; + + clusterStub = {} as Cluster; + + actualPromise = router.route(clusterStub, requestStub, responseStub); + }); + + it("calls handler with the request", () => { + expect(routeHandlerMock).toHaveBeenCalledWith({ + cluster: clusterStub, + params: {}, + path: "/some-path", + payload: "some-payload", + query: expect.any(URLSearchParams), + raw: { req: requestStub, res: responseStub }, + }); + }); + + it("given no content-type is specified, when handler resolves, resolves with JSON", async () => { + await routeHandlerMock.resolve({ response: "some-response-from-route-handler" }); + + await actualPromise; + + expect(responseStub.setHeader.mock.calls).toEqual([ + ["Content-Type", "application/json"], + ]); + }); + + it("given JSON content-type is specified, when handler resolves with object, resolves with JSON", async () => { + await routeHandlerMock.resolve({ response: { some: "object" }}); + + await actualPromise; + + expect(responseStub.end).toHaveBeenCalledWith('{"some":"object"}'); + }); + + describe("when handler resolves without any result", () => { + beforeEach(async () => { + await routeHandlerMock.resolve(undefined); + + await actualPromise; + }); + + it("resolves as plain text", () => { + expect(responseStub.setHeader.mock.calls).toEqual([["Content-Type", "text/plain"]]); + }); + + it("resolves with status code for no content", async () => { + expect(responseStub.statusCode).toBe(204); + }); + + it("resolves without content", async () => { + expect(responseStub.end.mock.calls).toEqual([[]]); + }); + }); + + describe("when handler rejects", () => { + beforeEach(async () => { + await routeHandlerMock.reject(new Error("some-error")); + + await actualPromise; + }); + + it("resolves as plain text", () => { + expect(responseStub.setHeader.mock.calls).toEqual([["Content-Type", "text/plain"]]); + }); + + it('resolves with "500" status code', () => { + expect(responseStub.statusCode).toBe(500); + }); + + it("resolves with error", () => { + expect(responseStub.end).toHaveBeenCalledWith("Error: some-error"); + }); + }); + + [ + { contentType: "text/plain", contentTypeObject: contentTypes.txt }, + { contentType: "application/json", contentTypeObject: contentTypes.json }, + { contentType: "text/html", contentTypeObject: contentTypes.html }, + { contentType: "text/css", contentTypeObject: contentTypes.css }, + { contentType: "image/gif", contentTypeObject: contentTypes.gif }, + { contentType: "image/jpeg", contentTypeObject: contentTypes.jpg }, + { contentType: "image/png", contentTypeObject: contentTypes.png }, + { contentType: "image/svg+xml", contentTypeObject: contentTypes.svg }, + { contentType: "application/javascript", contentTypeObject: contentTypes.js }, + { contentType: "font/woff2", contentTypeObject: contentTypes.woff2 }, + { contentType: "font/ttf", contentTypeObject: contentTypes.ttf }, + ].forEach(scenario => { + describe(`given content type is "${scenario.contentType}", when handler resolves with response`, () => { + beforeEach(async () => { + await routeHandlerMock.resolve({ response: "some-response", contentType: scenario.contentTypeObject }); + + await actualPromise; + }); + + it("has content type specific headers", () => { + expect(responseStub.setHeader.mock.calls).toEqual([ + ["Content-Type", scenario.contentType], + ]); + }); + + it("defaults to successful status code", () => { + expect(responseStub.statusCode).toBe(200); + }); + + it("has response as body", () => { + expect(responseStub.end).toHaveBeenCalledWith("some-response"); + }); + }); + + it(`given content type is "${scenario.contentType}", when handler resolves with success and custom status code, defaults to "200" as status code`, async () => { + await routeHandlerMock.resolve({ + response: "some-response", + statusCode: 204, + contentType: scenario.contentTypeObject, + }); + + await actualPromise; + + expect(responseStub.statusCode).toBe(204); + }); + + it(`given content type is "${scenario.contentType}", when handler resolves with success but without status code, defaults to "200" as status code`, async () => { + await routeHandlerMock.resolve({ + response: "some-response", + contentType: scenario.contentTypeObject, + }); + + await actualPromise; + + expect(responseStub.statusCode).toBe(200); + }); + + + it(`given content type is "${scenario.contentType}", when handler resolves without response, has no body`, async () => { + await routeHandlerMock.resolve({ + response: undefined, + contentType: scenario.contentTypeObject, + }); + + await actualPromise; + + expect(responseStub.end.mock.calls).toEqual([[]]); + }); + + it(`given content type is "${scenario.contentType}", when handler resolves with error, has error as body`, async () => { + await routeHandlerMock.resolve({ + error: "some-error", + contentType: scenario.contentTypeObject, + }); + + await actualPromise; + + expect(responseStub.end).toHaveBeenCalledWith("some-error"); + }); + + it(`given content type is "${scenario.contentType}", when handler resolves with error and status code, has custom status code`, async () => { + await routeHandlerMock.resolve({ + error: "some-error", + statusCode: 414, + contentType: scenario.contentTypeObject, + }); + + await actualPromise; + + expect(responseStub.statusCode).toBe(414); + }); + + it(`given content type is "${scenario.contentType}", when handler resolves with error but without status code, defaults to "400" as status code`, async () => { + await routeHandlerMock.resolve({ + error: "some-error", + contentType: scenario.contentTypeObject, + }); + + await actualPromise; + + expect(responseStub.statusCode).toBe(400); + }); + + it(`given content type is "${scenario.contentType}", when handler resolves custom headers, resolves with content type specific headers and custom headers`, async () => { + await routeHandlerMock.resolve({ + response: "irrelevant", + + headers: { + "Content-Type": "some-content-type-to-be-overridden", + "Some-Header": "some-header-value", + }, + + contentType: scenario.contentTypeObject, + }); + + await actualPromise; + + expect(responseStub.setHeader.mock.calls).toEqual([ + ["Content-Type", scenario.contentType], + ["Some-Header", "some-header-value"], + ]); + + }); + + describe(`given content type is "${scenario.contentType}", when handler resolves with binary content`, () => { + let responseBufferStub: Buffer; + + beforeEach(async () => { + responseBufferStub = Buffer.from("some-binary-content"); + + await routeHandlerMock.resolve({ + response: responseBufferStub, + contentType: scenario.contentTypeObject, + }); + + await actualPromise; + }); + + it("writes binary content to response", () => { + expect(responseStub.write).toHaveBeenCalledWith(responseBufferStub); + }); + + it("does not end with the response", () => { + expect(responseStub.end.mock.calls[0]).toEqual([]); + }); + }); + }); + }); +}); diff --git a/src/main/router/router.ts b/src/main/router/router.ts new file mode 100644 index 0000000000..ca5da23ce1 --- /dev/null +++ b/src/main/router/router.ts @@ -0,0 +1,196 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import Call from "@hapi/call"; +import type http from "http"; +import type httpProxy from "http-proxy"; +import { toPairs } from "lodash/fp"; +import path from "path"; +import type { Cluster } from "../../common/cluster/cluster"; +import type { LensApiResultContentType } from "./router-content-types"; +import { contentTypes } from "./router-content-types"; + +// TODO: Import causes side effect, sets value for __static +import "../../common/vars"; + +export interface RouterRequestOpts { + req: http.IncomingMessage; + res: http.ServerResponse; + cluster: Cluster; + params: RouteParams; + url: URL; +} + +export interface RouteParams extends Record { + path?: string; // *-route + namespace?: string; + service?: string; + account?: string; + release?: string; + repo?: string; + chart?: string; +} + +export interface LensApiRequest

{ + path: string; + payload: P; + params: RouteParams; + cluster: Cluster; + query: URLSearchParams; + raw: { + req: http.IncomingMessage; + res: http.ServerResponse; + }; +} + +interface Dependencies { + parseRequest: (request: http.IncomingMessage, _: null, options: { parse: boolean; output: string }) => Promise<{ payload: any }>; +} + +export class Router { + protected router = new Call.Router(); + protected static rootPath = path.resolve(__static); + + constructor(routes: Route[], private dependencies: Dependencies) { + routes.forEach(route => { + this.router.add({ method: route.method, path: route.path }, handleRoute(route)); + }); + } + + public async route(cluster: Cluster, req: http.IncomingMessage, res: http.ServerResponse): Promise { + const url = new URL(req.url, "http://localhost"); + const path = url.pathname; + const method = req.method.toLowerCase(); + const matchingRoute = this.router.route(method, path); + const routeFound = !matchingRoute.isBoom; + + if (routeFound) { + const request = await this.getRequest({ req, res, cluster, url, params: matchingRoute.params }); + + await matchingRoute.route(request, res); + + return true; + } + + return false; + } + + protected async getRequest(opts: RouterRequestOpts): Promise { + const { req, res, url, cluster, params } = opts; + + const { payload } = await this.dependencies.parseRequest(req, null, { + parse: true, + output: "data", + }); + + return { + cluster, + path: url.pathname, + raw: { + req, res, + }, + query: url.searchParams, + payload, + params, + }; + } +} + +export interface LensApiResult { + statusCode?: number; + response?: TResult; + error?: any; + contentType?: LensApiResultContentType; + headers?: { [name: string]: string }; + proxy?: httpProxy; +} + +export type RouteHandler = ( + request: LensApiRequest +) => + | Promise> + | Promise + | LensApiResult + | void; + +export interface Route { + path: string; + method: "get" | "post" | "put" | "patch" | "delete"; + handler: RouteHandler; +} + +const handleRoute = (route: Route) => async (request: LensApiRequest, response: http.ServerResponse) => { + let result: LensApiResult | void; + + const writeServerResponse = writeServerResponseFor(response); + + try { + result = await route.handler(request); + } catch(error) { + const mappedResult = contentTypes.txt.resultMapper({ + statusCode: 500, + error: error.toString(), + }); + + writeServerResponse(mappedResult); + + return; + } + + if (!result) { + const mappedResult = contentTypes.txt.resultMapper({ + statusCode: 204, + response: undefined, + }); + + writeServerResponse(mappedResult); + + return; + } + + if (result.proxy) { + return; + } + + const contentType = result.contentType || contentTypes.json; + + const mappedResult = contentType.resultMapper(result); + + writeServerResponse(mappedResult); +}; + +const writeServerResponseFor = + (serverResponse: http.ServerResponse) => + ({ + statusCode, + content, + headers, + }: { + statusCode: number; + content: any; + headers: { [name: string]: string }; + }) => { + serverResponse.statusCode = statusCode; + + const headerPairs = toPairs(headers); + + headerPairs.forEach(([name, value]) => { + serverResponse.setHeader(name, value); + }); + + if (content instanceof Buffer) { + serverResponse.write(content); + + serverResponse.end(); + + return; + } + + if (content) { + serverResponse.end(content); + } else { + serverResponse.end(); + } + }; diff --git a/src/main/routes/helm-route.ts b/src/main/routes/helm-route.ts deleted file mode 100644 index 958f729228..0000000000 --- a/src/main/routes/helm-route.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { LensApiRequest } from "../router"; -import { helmService } from "../helm/helm-service"; -import logger from "../logger"; -import { respondJson, respondText } from "../utils/http-responses"; -import { getBoolean } from "../utils/parse-query"; - -export class HelmApiRoute { - static async listCharts(request: LensApiRequest) { - const { response } = request; - const charts = await helmService.listCharts(); - - respondJson(response, charts); - } - - static async getChart(request: LensApiRequest) { - const { params, query, response } = request; - - try { - const chart = await helmService.getChart(params.repo, params.chart, query.get("version")); - - respondJson(response, chart); - } catch (error) { - respondText(response, error?.toString() || "Error getting chart", 422); - } - } - - static async getChartValues(request: LensApiRequest) { - const { params, query, response } = request; - - try { - const values = await helmService.getChartValues(params.repo, params.chart, query.get("version")); - - respondJson(response, values); - } catch (error) { - respondText(response, error?.toString() || "Error getting chart values", 422); - } - } - - static async installChart(request: LensApiRequest) { - const { payload, cluster, response } = request; - - try { - const result = await helmService.installChart(cluster, payload); - - respondJson(response, result, 201); - } catch (error) { - logger.debug(error); - respondText(response, error?.toString() || "Error installing chart", 422); - } - } - - static async updateRelease(request: LensApiRequest) { - const { cluster, params, payload, response } = request; - - try { - const result = await helmService.updateRelease(cluster, params.release, params.namespace, payload ); - - respondJson(response, result); - } catch (error) { - logger.debug(error); - respondText(response, error?.toString() || "Error updating chart", 422); - } - } - - static async rollbackRelease(request: LensApiRequest) { - const { cluster, params, payload, response } = request; - - try { - await helmService.rollback(cluster, params.release, params.namespace, payload.revision); - - response.end(); - } catch (error) { - logger.debug(error); - respondText(response, error?.toString() || "Error rolling back chart", 422); - } - } - - static async listReleases(request: LensApiRequest) { - const { cluster, params, response } = request; - - try { - const result = await helmService.listReleases(cluster, params.namespace); - - respondJson(response, result); - } catch(error) { - logger.debug(error); - respondText(response, error?.toString() || "Error listing release", 422); - } - } - - static async getRelease(request: LensApiRequest) { - const { cluster, params, response } = request; - - try { - const result = await helmService.getRelease(cluster, params.release, params.namespace); - - respondJson(response, result); - } catch (error) { - logger.debug(error); - respondText(response, error?.toString() || "Error getting release", 422); - } - } - - static async getReleaseValues(request: LensApiRequest) { - const { cluster, params: { namespace, release }, response, query } = request; - const all = getBoolean(query, "all"); - - try { - const result = await helmService.getReleaseValues(release, { cluster, namespace, all }); - - respondText(response, result); - } catch (error) { - logger.debug(error); - respondText(response, error?.toString() || "Error getting release values", 422); - } - } - - static async getReleaseHistory(request: LensApiRequest) { - const { cluster, params, response } = request; - - try { - const result = await helmService.getReleaseHistory(cluster, params.release, params.namespace); - - respondJson(response, result); - } catch (error) { - logger.debug(error); - respondText(response, error?.toString() || "Error getting release history", 422); - } - } - - static async deleteRelease(request: LensApiRequest) { - const { cluster, params, response } = request; - - try { - const result = await helmService.deleteRelease(cluster, params.release, params.namespace); - - respondJson(response, result); - } catch (error) { - logger.debug(error); - respondText(response, error?.toString() || "Error deleting release", 422); - } - } -} diff --git a/src/main/routes/helm/charts/get-chart-route.injectable.ts b/src/main/routes/helm/charts/get-chart-route.injectable.ts new file mode 100644 index 0000000000..523be0e1a6 --- /dev/null +++ b/src/main/routes/helm/charts/get-chart-route.injectable.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { routeInjectionToken } from "../../../router/router.injectable"; +import type { Route } from "../../../router/router"; +import { helmService } from "../../../helm/helm-service"; +import { apiPrefix } from "../../../../common/vars"; +import type { RawHelmChart } from "../../../../common/k8s-api/endpoints/helm-charts.api"; + +interface GetChartResponse { + readme: string; + versions: RawHelmChart[]; +} + +const getChartRouteInjectable = getInjectable({ + id: "get-chart-route", + + instantiate: (): Route => ({ + method: "get", + path: `${apiPrefix}/v2/charts/{repo}/{chart}`, + + handler: async ({ params, query }) => ({ + response: await helmService.getChart( + params.repo, + params.chart, + query.get("version"), + ), + }), + }), + + injectionToken: routeInjectionToken, +}); + +export default getChartRouteInjectable; diff --git a/src/main/routes/helm/charts/get-chart-values-route.injectable.ts b/src/main/routes/helm/charts/get-chart-values-route.injectable.ts new file mode 100644 index 0000000000..4eeeb263ec --- /dev/null +++ b/src/main/routes/helm/charts/get-chart-values-route.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { routeInjectionToken } from "../../../router/router.injectable"; +import type { Route } from "../../../router/router"; +import { helmService } from "../../../helm/helm-service"; +import { apiPrefix } from "../../../../common/vars"; + +const getChartRouteValuesInjectable = getInjectable({ + id: "get-chart-route-values", + + instantiate: (): Route => ({ + method: "get", + path: `${apiPrefix}/v2/charts/{repo}/{chart}/values`, + + handler: async ({ + params, + query, + }) => ({ + response: await helmService.getChartValues( + params.repo, + params.chart, + query.get("version"), + ), + }), + }), + + injectionToken: routeInjectionToken, +}); + +export default getChartRouteValuesInjectable; diff --git a/src/main/routes/helm/charts/list-charts-route.injectable.ts b/src/main/routes/helm/charts/list-charts-route.injectable.ts new file mode 100644 index 0000000000..a1273ca7af --- /dev/null +++ b/src/main/routes/helm/charts/list-charts-route.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { routeInjectionToken } from "../../../router/router.injectable"; +import type { Route } from "../../../router/router"; +import { helmService } from "../../../helm/helm-service"; +import { apiPrefix } from "../../../../common/vars"; + +const listChartsRouteInjectable = getInjectable({ + id: "list-charts-route", + + instantiate: (): Route => ({ + method: "get", + path: `${apiPrefix}/v2/charts`, + + handler: async () => ({ + response: await helmService.listCharts(), + }), + }), + + injectionToken: routeInjectionToken, +}); + +export default listChartsRouteInjectable; diff --git a/src/main/routes/helm/releases/delete-release-route.injectable.ts b/src/main/routes/helm/releases/delete-release-route.injectable.ts new file mode 100644 index 0000000000..6487cc7ce8 --- /dev/null +++ b/src/main/routes/helm/releases/delete-release-route.injectable.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { apiPrefix } from "../../../../common/vars"; +import type { Route } from "../../../router/router"; +import { helmService } from "../../../helm/helm-service"; +import { routeInjectionToken } from "../../../router/router.injectable"; +import { getInjectable } from "@ogre-tools/injectable"; + +const deleteReleaseRouteInjectable = getInjectable({ + id: "delete-release-route", + + instantiate: (): Route => ({ + method: "delete", + path: `${apiPrefix}/v2/releases/{namespace}/{release}`, + + handler: async (request) => { + const { cluster, params } = request; + + return { + response: await helmService.deleteRelease( + cluster, + params.release, + params.namespace, + ), + }; + }, + }), + + injectionToken: routeInjectionToken, +}); + +export default deleteReleaseRouteInjectable; diff --git a/src/main/routes/helm/releases/get-release-history-route.injectable.ts b/src/main/routes/helm/releases/get-release-history-route.injectable.ts new file mode 100644 index 0000000000..5ceadb2695 --- /dev/null +++ b/src/main/routes/helm/releases/get-release-history-route.injectable.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { apiPrefix } from "../../../../common/vars"; +import type { Route } from "../../../router/router"; +import { helmService } from "../../../helm/helm-service"; +import { routeInjectionToken } from "../../../router/router.injectable"; +import { getInjectable } from "@ogre-tools/injectable"; + +const getReleaseRouteHistoryInjectable = getInjectable({ + id: "get-release-history-route", + + instantiate: (): Route => ({ + method: "get", + path: `${apiPrefix}/v2/releases/{namespace}/{release}/history`, + + handler: async (request) => { + const { cluster, params } = request; + + return { + response: await helmService.getReleaseHistory( + cluster, + params.release, + params.namespace, + ), + }; + }, + }), + + injectionToken: routeInjectionToken, +}); + +export default getReleaseRouteHistoryInjectable; diff --git a/src/main/routes/helm/releases/get-release-route.injectable.ts b/src/main/routes/helm/releases/get-release-route.injectable.ts new file mode 100644 index 0000000000..e11ab64466 --- /dev/null +++ b/src/main/routes/helm/releases/get-release-route.injectable.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { apiPrefix } from "../../../../common/vars"; +import type { Route } from "../../../router/router"; +import { helmService } from "../../../helm/helm-service"; +import { routeInjectionToken } from "../../../router/router.injectable"; +import { getInjectable } from "@ogre-tools/injectable"; + +const getReleaseRouteInjectable = getInjectable({ + id: "get-release-route", + + instantiate: (): Route => ({ + method: "get", + path: `${apiPrefix}/v2/releases/{namespace}/{release}`, + + handler: async (request) => { + const { cluster, params } = request; + + return { + response: await helmService.getRelease( + cluster, + params.release, + params.namespace, + ), + }; + }, + }), + + injectionToken: routeInjectionToken, +}); + +export default getReleaseRouteInjectable; diff --git a/src/main/routes/helm/releases/get-release-values-route.injectable.ts b/src/main/routes/helm/releases/get-release-values-route.injectable.ts new file mode 100644 index 0000000000..92227042e7 --- /dev/null +++ b/src/main/routes/helm/releases/get-release-values-route.injectable.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { apiPrefix } from "../../../../common/vars"; +import type { Route } from "../../../router/router"; +import { helmService } from "../../../helm/helm-service"; +import { routeInjectionToken } from "../../../router/router.injectable"; +import { getInjectable } from "@ogre-tools/injectable"; +import { getBoolean } from "../../../utils/parse-query"; +import { contentTypes } from "../../../router/router-content-types"; + +const getReleaseRouteValuesInjectable = getInjectable({ + id: "get-release-values-route", + + instantiate: (): Route => ({ + method: "get", + path: `${apiPrefix}/v2/releases/{namespace}/{release}/values`, + + handler: async (request) => { + const { cluster, params: { namespace, release }, query } = request; + const all = getBoolean(query, "all"); + + return { + response: await helmService.getReleaseValues(release, { + cluster, + namespace, + all, + }), + + contentType: contentTypes.txt, + }; + }, + }), + + injectionToken: routeInjectionToken, +}); + +export default getReleaseRouteValuesInjectable; diff --git a/src/main/routes/helm/releases/install-chart-route.injectable.ts b/src/main/routes/helm/releases/install-chart-route.injectable.ts new file mode 100644 index 0000000000..879e39c4ac --- /dev/null +++ b/src/main/routes/helm/releases/install-chart-route.injectable.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { apiPrefix } from "../../../../common/vars"; +import type { Route } from "../../../router/router"; +import { helmService } from "../../../helm/helm-service"; +import { routeInjectionToken } from "../../../router/router.injectable"; +import { getInjectable } from "@ogre-tools/injectable"; + +interface InstallChartResponse { + log: string; + release: { name: string; namespace: string }; +} + +const installChartRouteInjectable = getInjectable({ + id: "install-chart-route", + + instantiate: () : Route => ({ + method: "post", + path: `${apiPrefix}/v2/releases`, + + handler: async (request) => { + const { payload, cluster } = request; + + return { + response: await helmService.installChart(cluster, payload), + statusCode: 201, + }; + }, + }), + + injectionToken: routeInjectionToken, +}); + +export default installChartRouteInjectable; diff --git a/src/main/routes/helm/releases/list-releases-route.injectable.ts b/src/main/routes/helm/releases/list-releases-route.injectable.ts new file mode 100644 index 0000000000..b73c618ff3 --- /dev/null +++ b/src/main/routes/helm/releases/list-releases-route.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { apiPrefix } from "../../../../common/vars"; +import type { Route } from "../../../router/router"; +import { helmService } from "../../../helm/helm-service"; +import { routeInjectionToken } from "../../../router/router.injectable"; +import { getInjectable } from "@ogre-tools/injectable"; + +const listReleasesRouteInjectable = getInjectable({ + id: "list-releases-route", + + instantiate: (): Route> => ({ + method: "get", + path: `${apiPrefix}/v2/releases/{namespace?}`, + + handler: async (request) => { + const { cluster, params } = request; + + return { + response: await helmService.listReleases(cluster, params.namespace), + }; + }, + }), + + injectionToken: routeInjectionToken, +}); + +export default listReleasesRouteInjectable; diff --git a/src/main/routes/helm/releases/rollback-release-route.injectable.ts b/src/main/routes/helm/releases/rollback-release-route.injectable.ts new file mode 100644 index 0000000000..1880dc7e1e --- /dev/null +++ b/src/main/routes/helm/releases/rollback-release-route.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { apiPrefix } from "../../../../common/vars"; +import type { Route } from "../../../router/router"; +import { helmService } from "../../../helm/helm-service"; +import { routeInjectionToken } from "../../../router/router.injectable"; +import { getInjectable } from "@ogre-tools/injectable"; + +const rollbackReleaseRouteInjectable = getInjectable({ + id: "rollback-release-route", + + instantiate: (): Route => ({ + method: "put", + path: `${apiPrefix}/v2/releases/{namespace}/{release}/rollback`, + + handler: async (request) => { + const { cluster, params, payload } = request; + + await helmService.rollback(cluster, params.release, params.namespace, payload.revision); + }, + }), + + injectionToken: routeInjectionToken, +}); + +export default rollbackReleaseRouteInjectable; diff --git a/src/main/routes/helm/releases/update-release-route.injectable.ts b/src/main/routes/helm/releases/update-release-route.injectable.ts new file mode 100644 index 0000000000..f97dc1a2ad --- /dev/null +++ b/src/main/routes/helm/releases/update-release-route.injectable.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { apiPrefix } from "../../../../common/vars"; +import type { Route } from "../../../router/router"; +import { helmService } from "../../../helm/helm-service"; +import { routeInjectionToken } from "../../../router/router.injectable"; +import { getInjectable } from "@ogre-tools/injectable"; + +interface UpdateReleaseResponse { + log: string; + release: { name: string; namespace: string }; +} + +const updateReleaseRouteInjectable = getInjectable({ + id: "update-release-route", + + instantiate: (): Route => ({ + method: "put", + path: `${apiPrefix}/v2/releases/{namespace}/{release}`, + + handler: async ({ + cluster, + params, + payload, + }) => ({ + response: await helmService.updateRelease( + cluster, + params.release, + params.namespace, + payload, + ), + }), + }), + + injectionToken: routeInjectionToken, +}); + +export default updateReleaseRouteInjectable; diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts deleted file mode 100644 index 635d92b3ba..0000000000 --- a/src/main/routes/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -export * from "./kubeconfig-route"; -export * from "./metrics-route"; -export * from "./port-forward-route"; -export * from "./helm-route"; -export * from "./resource-applier-route"; -export * from "./version-route"; diff --git a/src/main/routes/kubeconfig-route.ts b/src/main/routes/kubeconfig-route.ts deleted file mode 100644 index 4ed5427d0a..0000000000 --- a/src/main/routes/kubeconfig-route.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { LensApiRequest } from "../router"; -import { respondJson } from "../utils/http-responses"; -import type { Cluster } from "../../common/cluster/cluster"; -import { CoreV1Api, V1Secret } from "@kubernetes/client-node"; - -function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster) { - const tokenData = Buffer.from(secret.data["token"], "base64"); - - return { - "apiVersion": "v1", - "kind": "Config", - "clusters": [ - { - "name": cluster.contextName, - "cluster": { - "server": cluster.apiUrl, - "certificate-authority-data": secret.data["ca.crt"], - }, - }, - ], - "users": [ - { - "name": username, - "user": { - "token": tokenData.toString("utf8"), - }, - }, - ], - "contexts": [ - { - "name": cluster.contextName, - "context": { - "user": username, - "cluster": cluster.contextName, - "namespace": secret.metadata.namespace, - }, - }, - ], - "current-context": cluster.contextName, - }; -} - -export class KubeconfigRoute { - static async routeServiceAccountRoute(request: LensApiRequest) { - const { params, response, cluster } = request; - const client = (await cluster.getProxyKubeconfig()).makeApiClient(CoreV1Api); - const secretList = await client.listNamespacedSecret(params.namespace); - const secret = secretList.body.items.find(secret => { - const { annotations } = secret.metadata; - - return annotations && annotations["kubernetes.io/service-account.name"] == params.account; - }); - const data = generateKubeConfig(params.account, secret, cluster); - - respondJson(response, data); - } -} diff --git a/src/main/routes/kubeconfig-route/get-service-account-route.injectable.ts b/src/main/routes/kubeconfig-route/get-service-account-route.injectable.ts new file mode 100644 index 0000000000..c4eec032fe --- /dev/null +++ b/src/main/routes/kubeconfig-route/get-service-account-route.injectable.ts @@ -0,0 +1,75 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable } from "@ogre-tools/injectable"; +import { apiPrefix } from "../../../common/vars"; +import type { Route } from "../../router/router"; +import { routeInjectionToken } from "../../router/router.injectable"; +import type { Cluster } from "../../../common/cluster/cluster"; +import { CoreV1Api, V1Secret } from "@kubernetes/client-node"; + +const getServiceAccountRouteInjectable = getInjectable({ + id: "get-service-account-route", + + instantiate: (): Route> => ({ + method: "get", + path: `${apiPrefix}/kubeconfig/service-account/{namespace}/{account}`, + + handler: async (request) => { + const { params, cluster } = request; + const client = (await cluster.getProxyKubeconfig()).makeApiClient(CoreV1Api); + const secretList = await client.listNamespacedSecret(params.namespace); + + const secret = secretList.body.items.find(secret => { + const { annotations } = secret.metadata; + + return annotations && annotations["kubernetes.io/service-account.name"] == params.account; + }); + + return { response: generateKubeConfig(params.account, secret, cluster) }; + }, + }), + + injectionToken: routeInjectionToken, +}); + +export default getServiceAccountRouteInjectable; + +function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster) { + const tokenData = Buffer.from(secret.data["token"], "base64"); + + return { + "apiVersion": "v1", + "kind": "Config", + "clusters": [ + { + "name": cluster.contextName, + "cluster": { + "server": cluster.apiUrl, + "certificate-authority-data": secret.data["ca.crt"], + }, + }, + ], + "users": [ + { + "name": username, + "user": { + "token": tokenData.toString("utf8"), + }, + }, + ], + "contexts": [ + { + "name": cluster.contextName, + "context": { + "user": username, + "cluster": cluster.contextName, + "namespace": secret.metadata.namespace, + }, + }, + ], + "current-context": cluster.contextName, + }; +} diff --git a/src/main/routes/metrics-route.ts b/src/main/routes/metrics-route.ts deleted file mode 100644 index 083801680f..0000000000 --- a/src/main/routes/metrics-route.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { LensApiRequest } from "../router"; -import { respondJson } from "../utils/http-responses"; -import type { Cluster } from "../../common/cluster/cluster"; -import { ClusterMetadataKey, ClusterPrometheusMetadata } from "../../common/cluster-types"; -import logger from "../logger"; -import { getMetrics } from "../k8s-request"; -import { PrometheusProviderRegistry } from "../prometheus"; - -export type IMetricsQuery = string | string[] | { - [metricName: string]: string; -}; - -// This is used for backoff retry tracking. -const ATTEMPTS = [false, false, false, false, true]; - -// prometheus metrics loader -async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPath: string, queryParams: Record): Promise { - const queries = promQueries.map(p => p.trim()); - const loaders = new Map>(); - - async function loadMetric(query: string): Promise { - async function loadMetricHelper(): Promise { - for (const [attempt, lastAttempt] of ATTEMPTS.entries()) { // retry - try { - return await getMetrics(cluster, prometheusPath, { query, ...queryParams }); - } catch (error) { - if (lastAttempt || (error?.statusCode >= 400 && error?.statusCode < 500)) { - logger.error("[Metrics]: metrics not available", error?.response ? error.response?.body : error); - throw new Error("Metrics not available"); - } - - await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 1000)); // add delay before repeating request - } - } - } - - return loaders.get(query) ?? loaders.set(query, loadMetricHelper()).get(query); - } - - return Promise.all(queries.map(loadMetric)); -} - -interface MetricProviderInfo { - name: string; - id: string; - isConfigurable: boolean; -} - -export class MetricsRoute { - static async routeMetrics({ response, cluster, payload, query }: LensApiRequest) { - const queryParams: IMetricsQuery = Object.fromEntries(query.entries()); - const prometheusMetadata: ClusterPrometheusMetadata = {}; - - try { - const { prometheusPath, provider } = await cluster.contextHandler.getPrometheusDetails(); - - prometheusMetadata.provider = provider?.id; - prometheusMetadata.autoDetected = !cluster.preferences.prometheusProvider?.type; - - if (!prometheusPath) { - prometheusMetadata.success = false; - - return respondJson(response, {}); - } - - // return data in same structure as query - if (typeof payload === "string") { - const [data] = await loadMetrics([payload], cluster, prometheusPath, queryParams); - - respondJson(response, data); - } else if (Array.isArray(payload)) { - const data = await loadMetrics(payload, cluster, prometheusPath, queryParams); - - respondJson(response, data); - } else { - const queries = Object.entries>(payload) - .map(([queryName, queryOpts]) => ( - provider.getQuery(queryOpts, queryName) - )); - const result = await loadMetrics(queries, cluster, prometheusPath, queryParams); - const data = Object.fromEntries(Object.keys(payload).map((metricName, i) => [metricName, result[i]])); - - respondJson(response, data); - } - prometheusMetadata.success = true; - } catch (error) { - prometheusMetadata.success = false; - respondJson(response, {}); - logger.warn(`[METRICS-ROUTE]: failed to get metrics for clusterId=${cluster.id}:`, error); - } finally { - cluster.metadata[ClusterMetadataKey.PROMETHEUS] = prometheusMetadata; - } - } - - static async routeMetricsProviders({ response }: LensApiRequest) { - const providers: MetricProviderInfo[] = []; - - for (const { name, id, isConfigurable } of PrometheusProviderRegistry.getInstance().providers.values()) { - providers.push({ name, id, isConfigurable }); - } - - respondJson(response, providers); - } -} diff --git a/src/main/routes/metrics/add-metrics-route.injectable.ts b/src/main/routes/metrics/add-metrics-route.injectable.ts new file mode 100644 index 0000000000..4dc2158488 --- /dev/null +++ b/src/main/routes/metrics/add-metrics-route.injectable.ts @@ -0,0 +1,108 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable } from "@ogre-tools/injectable"; +import { apiPrefix } from "../../../common/vars"; +import type { LensApiRequest, Route } from "../../router/router"; +import { routeInjectionToken } from "../../router/router.injectable"; +import { ClusterMetadataKey, ClusterPrometheusMetadata } from "../../../common/cluster-types"; +import logger from "../../logger"; +import type { Cluster } from "../../../common/cluster/cluster"; +import { getMetrics } from "../../k8s-request"; +import type { IMetricsQuery } from "./metrics-query"; + +// This is used for backoff retry tracking. +const ATTEMPTS = [false, false, false, false, true]; + +async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPath: string, queryParams: Record): Promise { + const queries = promQueries.map(p => p.trim()); + const loaders = new Map>(); + + async function loadMetric(query: string): Promise { + async function loadMetricHelper(): Promise { + for (const [attempt, lastAttempt] of ATTEMPTS.entries()) { // retry + try { + return await getMetrics(cluster, prometheusPath, { query, ...queryParams }); + } catch (error) { + if (lastAttempt || (error?.statusCode >= 400 && error?.statusCode < 500)) { + logger.error("[Metrics]: metrics not available", error?.response ? error.response?.body : error); + throw new Error("Metrics not available"); + } + + await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 1000)); // add delay before repeating request + } + } + } + + return loaders.get(query) ?? loaders.set(query, loadMetricHelper()).get(query); + } + + return Promise.all(queries.map(loadMetric)); +} + +const addMetricsRoute = async ({ cluster, payload, query }: LensApiRequest) => { + const queryParams: IMetricsQuery = Object.fromEntries(query.entries()); + const prometheusMetadata: ClusterPrometheusMetadata = {}; + + try { + const { prometheusPath, provider } = await cluster.contextHandler.getPrometheusDetails(); + + prometheusMetadata.provider = provider?.id; + prometheusMetadata.autoDetected = !cluster.preferences.prometheusProvider?.type; + + if (!prometheusPath) { + prometheusMetadata.success = false; + + return { response: {}}; + } + + // return data in same structure as query + if (typeof payload === "string") { + const [data] = await loadMetrics([payload], cluster, prometheusPath, queryParams); + + return { response: data }; + } + + if (Array.isArray(payload)) { + const data = await loadMetrics(payload, cluster, prometheusPath, queryParams); + + return { response: data }; + } + + const queries = Object.entries>(payload) + .map(([queryName, queryOpts]) => ( + provider.getQuery(queryOpts, queryName) + )); + + const result = await loadMetrics(queries, cluster, prometheusPath, queryParams); + const data = Object.fromEntries(Object.keys(payload).map((metricName, i) => [metricName, result[i]])); + + prometheusMetadata.success = true; + + return { response: data }; + } catch (error) { + prometheusMetadata.success = false; + + logger.warn(`[METRICS-ROUTE]: failed to get metrics for clusterId=${cluster.id}:`, error); + + return { response: {}}; + } finally { + cluster.metadata[ClusterMetadataKey.PROMETHEUS] = prometheusMetadata; + } +}; + +const addMetricsRouteInjectable = getInjectable({ + id: "add-metrics-route", + + instantiate: (): Route => ({ + method: "post", + path: `${apiPrefix}/metrics`, + handler: addMetricsRoute, + }), + + injectionToken: routeInjectionToken, +}); + +export default addMetricsRouteInjectable; diff --git a/src/main/routes/metrics/get-metric-providers-route.injectable.ts b/src/main/routes/metrics/get-metric-providers-route.injectable.ts new file mode 100644 index 0000000000..8fcba9d984 --- /dev/null +++ b/src/main/routes/metrics/get-metric-providers-route.injectable.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable } from "@ogre-tools/injectable"; +import { apiPrefix } from "../../../common/vars"; +import type { Route } from "../../router/router"; +import { routeInjectionToken } from "../../router/router.injectable"; +import { PrometheusProviderRegistry } from "../../prometheus"; +import type { MetricProviderInfo } from "../../../common/k8s-api/endpoints/metrics.api"; + +const getMetricProvidersRouteInjectable = getInjectable({ + id: "get-metric-providers-route", + + instantiate: (): Route => ({ + method: "get", + path: `${apiPrefix}/metrics/providers`, + + handler: () => { + const providers: MetricProviderInfo[] = []; + + for (const { name, id, isConfigurable } of PrometheusProviderRegistry.getInstance().providers.values()) { + providers.push({ name, id, isConfigurable }); + } + + return { response: providers }; + }, + }), + + injectionToken: routeInjectionToken, +}); + +export default getMetricProvidersRouteInjectable; diff --git a/src/main/routes/metrics/metrics-query.ts b/src/main/routes/metrics/metrics-query.ts new file mode 100644 index 0000000000..d6d9532b3b --- /dev/null +++ b/src/main/routes/metrics/metrics-query.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +export type IMetricsQuery = string | string[] | { + [metricName: string]: string; +}; diff --git a/src/main/routes/port-forward-route.ts b/src/main/routes/port-forward-route.ts deleted file mode 100644 index 4d688d68a6..0000000000 --- a/src/main/routes/port-forward-route.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { LensApiRequest } from "../router"; -import logger from "../logger"; -import { respondJson } from "../utils/http-responses"; -import { PortForward } from "./port-forward/port-forward"; - -export class PortForwardRoute { - static async routeCurrentPortForward(request: LensApiRequest) { - const { params, query, response, cluster } = request; - const { namespace, resourceType, resourceName } = params; - const port = Number(query.get("port")); - const forwardPort = Number(query.get("forwardPort")); - - const portForward = PortForward.getPortforward({ - clusterId: cluster.id, kind: resourceType, name: resourceName, - namespace, port, forwardPort, - }); - - respondJson(response, { port: portForward?.forwardPort ?? null }); - } - - static async routeCurrentPortForwardStop(request: LensApiRequest) { - const { params, query, response, cluster } = request; - const { namespace, resourceType, resourceName } = params; - const port = Number(query.get("port")); - const forwardPort = Number(query.get("forwardPort")); - - const portForward = PortForward.getPortforward({ - clusterId: cluster.id, kind: resourceType, name: resourceName, - namespace, port, forwardPort, - }); - - try { - await portForward.stop(); - respondJson(response, { status: true }); - } catch (error) { - logger.error("[PORT-FORWARD-ROUTE]: error stopping a port-forward", { namespace, port, forwardPort, resourceType, resourceName }); - - return respondJson(response, { - message: `error stopping a forward port ${port}`, - }, 400); - } - } -} diff --git a/src/main/routes/port-forward/create-port-forward.injectable.ts b/src/main/routes/port-forward/functionality/create-port-forward.injectable.ts similarity index 89% rename from src/main/routes/port-forward/create-port-forward.injectable.ts rename to src/main/routes/port-forward/functionality/create-port-forward.injectable.ts index bc00f7d791..1bd7a1ea41 100644 --- a/src/main/routes/port-forward/create-port-forward.injectable.ts +++ b/src/main/routes/port-forward/functionality/create-port-forward.injectable.ts @@ -4,7 +4,7 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { PortForward, PortForwardArgs } from "./port-forward"; -import bundledKubectlInjectable from "../../kubectl/bundled-kubectl.injectable"; +import bundledKubectlInjectable from "../../../kubectl/bundled-kubectl.injectable"; const createPortForwardInjectable = getInjectable({ id: "create-port-forward", diff --git a/src/main/routes/port-forward/port-forward.ts b/src/main/routes/port-forward/functionality/port-forward.ts similarity index 96% rename from src/main/routes/port-forward/port-forward.ts rename to src/main/routes/port-forward/functionality/port-forward.ts index 964b4e6ff2..dcbfb3d752 100644 --- a/src/main/routes/port-forward/port-forward.ts +++ b/src/main/routes/port-forward/functionality/port-forward.ts @@ -2,8 +2,8 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import logger from "../../logger"; -import { getPortFrom } from "../../utils/get-port"; +import logger from "../../../logger"; +import { getPortFrom } from "../../../utils/get-port"; import { spawn, ChildProcessWithoutNullStreams } from "child_process"; import * as tcpPortUsed from "tcp-port-used"; diff --git a/src/main/routes/port-forward/get-current-port-forward-route.injectable.ts b/src/main/routes/port-forward/get-current-port-forward-route.injectable.ts new file mode 100644 index 0000000000..a5b83edbf6 --- /dev/null +++ b/src/main/routes/port-forward/get-current-port-forward-route.injectable.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { routeInjectionToken } from "../../router/router.injectable"; +import type { LensApiRequest, Route } from "../../router/router"; +import { apiPrefix } from "../../../common/vars"; +import { PortForward } from "./functionality/port-forward"; + +const getCurrentPortForward = async (request: LensApiRequest) => { + const { params, query, cluster } = request; + const { namespace, resourceType, resourceName } = params; + const port = Number(query.get("port")); + const forwardPort = Number(query.get("forwardPort")); + + const portForward = PortForward.getPortforward({ + clusterId: cluster.id, kind: resourceType, name: resourceName, + namespace, port, forwardPort, + }); + + return { response: { port: portForward?.forwardPort ?? null }}; +}; + +const getCurrentPortForwardRouteInjectable = getInjectable({ + id: "get-current-port-forward-route", + + instantiate: (): Route<{ port: number }> => ({ + method: "get", + path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}`, + handler: getCurrentPortForward, + }), + + injectionToken: routeInjectionToken, +}); + +export default getCurrentPortForwardRouteInjectable; diff --git a/src/main/routes/port-forward/route-port-forward/route-port-forward.injectable.ts b/src/main/routes/port-forward/route-port-forward/route-port-forward.injectable.ts deleted file mode 100644 index af7758d0eb..0000000000 --- a/src/main/routes/port-forward/route-port-forward/route-port-forward.injectable.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { routePortForward } from "./route-port-forward"; -import { getInjectable } from "@ogre-tools/injectable"; -import createPortForwardInjectable from "../create-port-forward.injectable"; - -const routePortForwardInjectable = getInjectable({ - id: "route-port-forward", - - instantiate: (di) => routePortForward({ - createPortForward: di.inject(createPortForwardInjectable), - }), -}); - -export default routePortForwardInjectable; diff --git a/src/main/routes/port-forward/route-port-forward/route-port-forward.ts b/src/main/routes/port-forward/route-port-forward/route-port-forward.ts deleted file mode 100644 index 267bc7529c..0000000000 --- a/src/main/routes/port-forward/route-port-forward/route-port-forward.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { LensApiRequest } from "../../../router"; -import logger from "../../../logger"; -import { respondJson } from "../../../utils/http-responses"; -import { PortForward, PortForwardArgs } from "../port-forward"; - -interface Dependencies { - createPortForward: (pathToKubeConfig: string, args: PortForwardArgs) => PortForward; -} - -export const routePortForward = - ({ createPortForward }: Dependencies) => - async (request: LensApiRequest) => { - const { params, query, response, cluster } = request; - const { namespace, resourceType, resourceName } = params; - const port = Number(query.get("port")); - const forwardPort = Number(query.get("forwardPort")); - - try { - let portForward = PortForward.getPortforward({ - clusterId: cluster.id, - kind: resourceType, - name: resourceName, - namespace, - port, - forwardPort, - }); - - if (!portForward) { - logger.info( - `Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`, - ); - - const thePort = - 0 < forwardPort && forwardPort < 65536 ? forwardPort : 0; - - portForward = createPortForward(await cluster.getProxyKubeconfigPath(), { - clusterId: cluster.id, - kind: resourceType, - namespace, - name: resourceName, - port, - forwardPort: thePort, - }); - - const started = await portForward.start(); - - if (!started) { - logger.error("[PORT-FORWARD-ROUTE]: failed to start a port-forward", { - namespace, - port, - resourceType, - resourceName, - }); - - return respondJson( - response, - { - message: `Failed to forward port ${port} to ${ - thePort ? forwardPort : "random port" - }`, - }, - 400, - ); - } - } - - respondJson(response, { port: portForward.forwardPort }); - } catch (error) { - logger.error( - `[PORT-FORWARD-ROUTE]: failed to open a port-forward: ${error}`, - { namespace, port, resourceType, resourceName }, - ); - - return respondJson( - response, - { - message: `Failed to forward port ${port}`, - }, - 400, - ); - } - }; diff --git a/src/main/routes/port-forward/start-port-forward-route.injectable.ts b/src/main/routes/port-forward/start-port-forward-route.injectable.ts new file mode 100644 index 0000000000..99f6624cd3 --- /dev/null +++ b/src/main/routes/port-forward/start-port-forward-route.injectable.ts @@ -0,0 +1,97 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { routeInjectionToken } from "../../router/router.injectable"; +import type { LensApiRequest, Route } from "../../router/router"; +import { apiPrefix } from "../../../common/vars"; +import { PortForward, PortForwardArgs } from "./functionality/port-forward"; +import logger from "../../logger"; +import createPortForwardInjectable from "./functionality/create-port-forward.injectable"; + +interface Dependencies { + createPortForward: (pathToKubeConfig: string, args: PortForwardArgs) => PortForward; +} + +const startPortForward = ({ createPortForward }: Dependencies) => async (request: LensApiRequest) => { + const { params, query, cluster } = request; + const { namespace, resourceType, resourceName } = params; + const port = Number(query.get("port")); + const forwardPort = Number(query.get("forwardPort")); + + try { + let portForward = PortForward.getPortforward({ + clusterId: cluster.id, + kind: resourceType, + name: resourceName, + namespace, + port, + forwardPort, + }); + + if (!portForward) { + logger.info( + `Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`, + ); + + const thePort = + 0 < forwardPort && forwardPort < 65536 ? forwardPort : 0; + + portForward = createPortForward(await cluster.getProxyKubeconfigPath(), { + clusterId: cluster.id, + kind: resourceType, + namespace, + name: resourceName, + port, + forwardPort: thePort, + }); + + const started = await portForward.start(); + + if (!started) { + logger.error("[PORT-FORWARD-ROUTE]: failed to start a port-forward", { + namespace, + port, + resourceType, + resourceName, + }); + + return { + error: { + message: `Failed to forward port ${port} to ${ + thePort ? forwardPort : "random port" + }`, + }, + }; + } + } + + return { response: { port: portForward.forwardPort }}; + } catch (error) { + logger.error( + `[PORT-FORWARD-ROUTE]: failed to open a port-forward: ${error}`, + { namespace, port, resourceType, resourceName }, + ); + + return { + error: { + message: `Failed to forward port ${port}`, + }, + }; + } +}; + +const startPortForwardRouteInjectable = getInjectable({ + id: "start-current-port-forward-route", + + instantiate: (di): Route<{ port: number }> => ({ + method: "post", + path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}`, + handler: startPortForward({ createPortForward: di.inject(createPortForwardInjectable) }), + }), + + injectionToken: routeInjectionToken, +}); + +export default startPortForwardRouteInjectable; diff --git a/src/main/routes/port-forward/stop-current-port-forward-route.injectable.ts b/src/main/routes/port-forward/stop-current-port-forward-route.injectable.ts new file mode 100644 index 0000000000..b9fce502c1 --- /dev/null +++ b/src/main/routes/port-forward/stop-current-port-forward-route.injectable.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { routeInjectionToken } from "../../router/router.injectable"; +import type { LensApiRequest, Route } from "../../router/router"; +import { apiPrefix } from "../../../common/vars"; +import { PortForward } from "./functionality/port-forward"; +import logger from "../../logger"; + +const stopCurrentPortForward = async (request: LensApiRequest) => { + const { params, query, cluster } = request; + const { namespace, resourceType, resourceName } = params; + const port = Number(query.get("port")); + const forwardPort = Number(query.get("forwardPort")); + + const portForward = PortForward.getPortforward({ + clusterId: cluster.id, kind: resourceType, name: resourceName, + namespace, port, forwardPort, + }); + + try { + await portForward.stop(); + + return { response: { status: true }}; + } catch (error) { + logger.error("[PORT-FORWARD-ROUTE]: error stopping a port-forward", { namespace, port, forwardPort, resourceType, resourceName }); + + return { + error: { + message: `error stopping a forward port ${port}`, + }, + }; + + } +}; + +const stopCurrentPortForwardRouteInjectable = getInjectable({ + id: "stop-current-port-forward-route", + + instantiate: (): Route<{ status: boolean }> => ({ + method: "delete", + path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}`, + handler: stopCurrentPortForward, + }), + + injectionToken: routeInjectionToken, +}); + +export default stopCurrentPortForwardRouteInjectable; diff --git a/src/main/routes/resource-applier-route.ts b/src/main/routes/resource-applier-route.ts deleted file mode 100644 index 1072050b9a..0000000000 --- a/src/main/routes/resource-applier-route.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { LensApiRequest } from "../router"; -import { respondJson, respondText } from "../utils/http-responses"; -import { ResourceApplier } from "../resource-applier"; - -export class ResourceApplierApiRoute { - static async applyResource(request: LensApiRequest) { - const { response, cluster, payload } = request; - - try { - const resource = await new ResourceApplier(cluster).apply(payload); - - respondJson(response, resource, 200); - } catch (error) { - respondText(response, error, 422); - } - } - - static async patchResource(request: LensApiRequest) { - const { response, cluster, payload } = request; - - try { - const resource = await new ResourceApplier(cluster).patch(payload.name, payload.kind, payload.patch, payload.ns); - - respondJson(response, resource, 200); - } catch (error) { - respondText(response, error, 422); - } - } -} diff --git a/src/main/routes/resource-applier/apply-resource-route.injectable.ts b/src/main/routes/resource-applier/apply-resource-route.injectable.ts new file mode 100644 index 0000000000..b1db11cfa3 --- /dev/null +++ b/src/main/routes/resource-applier/apply-resource-route.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { routeInjectionToken } from "../../router/router.injectable"; +import type { Route } from "../../router/router"; +import { apiPrefix } from "../../../common/vars"; +import { ResourceApplier } from "../../resource-applier"; + +const applyResourceRouteInjectable = getInjectable({ + id: "apply-resource-route", + + instantiate: (): Route => ({ + method: "post", + path: `${apiPrefix}/stack`, + + handler: async ({ cluster, payload }) => ({ + response: await new ResourceApplier(cluster).apply(payload), + }), + }), + + injectionToken: routeInjectionToken, +}); + +export default applyResourceRouteInjectable; diff --git a/src/main/routes/resource-applier/patch-resource-route.injectable.ts b/src/main/routes/resource-applier/patch-resource-route.injectable.ts new file mode 100644 index 0000000000..2ac26db726 --- /dev/null +++ b/src/main/routes/resource-applier/patch-resource-route.injectable.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { routeInjectionToken } from "../../router/router.injectable"; +import type { Route } from "../../router/router"; +import { apiPrefix } from "../../../common/vars"; +import { ResourceApplier } from "../../resource-applier"; + +const patchResourceRouteInjectable = getInjectable({ + id: "patch-resource-route", + + instantiate: (): Route => ({ + method: "patch", + path: `${apiPrefix}/stack`, + + handler: async ({ cluster, payload }) => ({ + response: await new ResourceApplier(cluster).patch( + payload.name, + payload.kind, + payload.patch, + payload.ns, + ), + }), + }), + + injectionToken: routeInjectionToken, +}); + +export default patchResourceRouteInjectable; diff --git a/src/main/routes/static-file-route.injectable.ts b/src/main/routes/static-file-route.injectable.ts new file mode 100644 index 0000000000..f35976f320 --- /dev/null +++ b/src/main/routes/static-file-route.injectable.ts @@ -0,0 +1,94 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { LensApiRequest, Route } from "../router/router"; +import { contentTypes, SupportedFileExtension } from "../router/router-content-types"; +import logger from "../logger"; +import { routeInjectionToken } from "../router/router.injectable"; +import { appName, publicPath } from "../../common/vars"; +import path from "path"; +import readFileInjectable from "../../common/fs/read-file.injectable"; +import isDevelopmentInjectable from "../../common/vars/is-development.injectable"; +import httpProxy from "http-proxy"; + +interface ProductionDependencies { + readFile: (path: string) => Promise; +} + +const handleStaticFileInProduction = + ({ readFile }: ProductionDependencies) => + async ({ params }: LensApiRequest) => { + const staticPath = path.resolve(__static); + let filePath = params.path; + + for (let retryCount = 0; retryCount < 5; retryCount += 1) { + const asset = path.join(staticPath, filePath); + const normalizedFilePath = path.resolve(asset); + + if (!normalizedFilePath.startsWith(staticPath)) { + return { statusCode: 404 }; + } + + try { + const fileExtension = path + .extname(asset) + .slice(1) as SupportedFileExtension; + + const contentType = contentTypes[fileExtension] || contentTypes.txt; + + return { response: await readFile(asset), contentType }; + } catch (err) { + if (retryCount > 5) { + logger.error("handleStaticFile:", err.toString()); + + return { statusCode: 404 }; + } + + filePath = `${publicPath}/${appName}.html`; + } + } + + return { statusCode: 404 }; + }; + +interface DevelopmentDependencies { + proxy: httpProxy; +} + +const handleStaticFileInDevelopment = + ({ proxy }: DevelopmentDependencies) => + (apiReq: LensApiRequest) => { + const { req, res } = apiReq.raw; + + if (req.url === "/" || !req.url.startsWith("/build/")) { + req.url = `${publicPath}/${appName}.html`; + } + + proxy.web(req, res, { + target: "http://127.0.0.1:8080", + }); + + return { proxy }; + }; + +const staticFileRouteInjectable = getInjectable({ + id: "static-file-route", + + instantiate: (di): Route => { + const isDevelopment = di.inject(isDevelopmentInjectable); + + return { + method: "get", + path: `/{path*}`, + handler: isDevelopment + ? handleStaticFileInDevelopment({ proxy: httpProxy.createProxy() }) + : handleStaticFileInProduction({ readFile: di.inject(readFileInjectable) }), + }; + }, + + injectionToken: routeInjectionToken, +}); + +export default staticFileRouteInjectable; diff --git a/src/main/routes/version-route.ts b/src/main/routes/version-route.ts deleted file mode 100644 index 1c5c7c94d7..0000000000 --- a/src/main/routes/version-route.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { LensApiRequest } from "../router"; -import { respondJson } from "../utils/http-responses"; -import { getAppVersion } from "../../common/utils"; - -export class VersionRoute { - static async getVersion(request: LensApiRequest) { - const { response } = request; - - respondJson(response, { version: getAppVersion() }, 200); - } -} diff --git a/src/main/routes/versions/get-version-route.injectable.ts b/src/main/routes/versions/get-version-route.injectable.ts new file mode 100644 index 0000000000..357c09f22d --- /dev/null +++ b/src/main/routes/versions/get-version-route.injectable.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { Route } from "../../router/router"; +import { routeInjectionToken } from "../../router/router.injectable"; +import { getAppVersion } from "../../../common/utils"; + +const getVersionRouteInjectable = getInjectable({ + id: "get-version-route", + + instantiate: (): Route<{ version: string }> => ({ + method: "get", + path: `/version`, + + handler: () => ({ response: { version: getAppVersion() }}), + }), + + injectionToken: routeInjectionToken, +}); + +export default getVersionRouteInjectable; diff --git a/yarn.lock b/yarn.lock index c7c1ba7644..9af8e01278 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9047,6 +9047,11 @@ merge2@^1.3.0: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== +mergee@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/mergee/-/mergee-1.0.0.tgz#027c5addc650f6ecbe4bf56100bd00dae763fda7" + integrity sha512-hbbXD4LOcxVkpS+mp3BMEhkSDf+lTVENFeEeqACgjjL8WrgKuW2EyLT0fOHyTbyDiuRLZJZ1HrHNeiX4iOd79Q== + methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" @@ -9290,6 +9295,13 @@ mock-fs@^5.1.2: resolved "https://registry.yarnpkg.com/mock-fs/-/mock-fs-5.1.2.tgz#6fa486e06d00f8793a8d2228de980eff93ce6db7" integrity sha512-YkjQkdLulFrz0vD4BfNQdQRVmgycXTV7ykuHMlyv+C8WCHazpkiQRDthwa02kSyo8wKnY9wRptHfQLgmf0eR+A== +mock-http@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/mock-http/-/mock-http-1.1.0.tgz#b89380a718a103fc5801095804bedd0b20f7638c" + integrity sha512-H2HMGaHNQPWY8PdeEw4RFux2WEOHD6eJAtN3+iFELik5kGjPKAcoyPWcsC2vgDiTa2yimAEDssmMed51e+cBKQ== + dependencies: + mergee "^1.0.0" + moment-timezone@^0.5.34: version "0.5.34" resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.34.tgz#a75938f7476b88f155d3504a9343f7519d9a405c"