1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Make routes in back-end comply to Open Closed Principle (#4859)

* Make registration of back-end routes happen using injectable

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using injectable to setup route for list charts

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using injectable to setup route for get chart

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using injectable to setup route for get chart values

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using injectable to setup route for installing chart

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using injectable to setup route for updating release

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using injectable to setup route for rollbacking release

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using injectable to setup route for listing releases

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using injectable to setup route for getting release

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using injectable to setup route for getting release values

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using injectable to setup route for getting release history

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using injectable to setup route for deleting release

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove HelmRoute for not being used anymore

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using injectable to setup route for applying resource

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using injectable to setup route for patching resource

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using injectable to setup route for stopping current port forward

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using injectable to setup route for getting current port forward

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Consolidate start port forward route to use injection token for registering route

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using injectable to setup route for metrics

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using injectable to setup route for getting service account

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using injectable to setup route for getting version

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Add global override for reading file to make it not required where not interesting

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using injectable to setup route for serving static files

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make adding routes private for router

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make routes wait until all asynchronous stuff are done

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Introduce healthy abstraction between back-end routes and route handlers

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make response of route typed

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix incorrect return value of updateRelease

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make typing of routes support synchronous values

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make code cleaner

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Rename test for accuracy

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Add test for throwing route handler

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove duplicate license header

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make mechanism of creating HTTP response an implementation detail

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove not needed properties from test

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix code style

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix typing and codestyle

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix typing error

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix merge conflicts

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Organize all router related files under directory

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
This commit is contained in:
Janne Savolainen 2022-03-11 12:20:00 +02:00 committed by GitHub
parent 772e879b81
commit 38af26efc9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 1646 additions and 810 deletions

View File

@ -334,6 +334,7 @@
"@typescript-eslint/eslint-plugin": "^5.10.1", "@typescript-eslint/eslint-plugin": "^5.10.1",
"@typescript-eslint/parser": "^5.10.1", "@typescript-eslint/parser": "^5.10.1",
"ansi_up": "^5.1.0", "ansi_up": "^5.1.0",
"mock-http": "^1.1.0",
"chart.js": "^2.9.4", "chart.js": "^2.9.4",
"circular-dependency-plugin": "^5.2.2", "circular-dependency-plugin": "^5.2.2",
"cli-progress": "^3.10.0", "cli-progress": "^3.10.0",

View File

@ -7,7 +7,7 @@
import moment from "moment"; import moment from "moment";
import { apiBase } from "../index"; import { apiBase } from "../index";
import type { IMetricsQuery } from "../../../main/routes/metrics-route"; import type { IMetricsQuery } from "../../../main/routes/metrics/metrics-query";
export interface IMetrics { export interface IMetrics {
status: string; status: string;

View File

@ -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);
});
});

View File

@ -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<Buffer>;
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<any>;
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<any>;
const result = await handleStaticFileRoute.handler(request);
expect(result).toEqual({ statusCode: 404 });
});
});

View File

@ -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 registerChannelInjectable from "./app-paths/register-channel/register-channel.injectable";
import writeJsonFileInjectable from "../common/fs/write-json-file.injectable"; import writeJsonFileInjectable from "../common/fs/write-json-file.injectable";
import readJsonFileInjectable from "../common/fs/read-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"; import directoryForBundledBinariesInjectable from "../common/app-paths/directory-for-bundled-binaries/directory-for-bundled-binaries.injectable";
export const getDiForUnitTesting = ( export const getDiForUnitTesting = (
@ -53,6 +54,10 @@ export const getDiForUnitTesting = (
di.override(readJsonFileInjectable, () => () => { di.override(readJsonFileInjectable, () => () => {
throw new Error("Tried to read JSON file from file system without specifying explicit override."); 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; return di;

View File

@ -90,9 +90,11 @@ export async function upgradeRelease(name: string, chart: string, values: any, n
]; ];
try { try {
const output = await execHelm(args);
return { return {
log: await execHelm(args), log: output,
release: getRelease(name, namespace, kubeconfigPath, kubectlPath), release: await getRelease(name, namespace, kubeconfigPath, kubectlPath),
}; };
} finally { } finally {
await fse.unlink(valuesFilePath); await fse.unlink(valuesFilePath);

View File

@ -8,7 +8,7 @@ import type http from "http";
import spdy from "spdy"; import spdy from "spdy";
import type httpProxy from "http-proxy"; import type httpProxy from "http-proxy";
import { apiPrefix, apiKubePrefix } from "../common/vars"; 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 type { ContextHandler } from "./context-handler/context-handler";
import logger from "./logger"; import logger from "./logger";
import { Singleton } from "../common/utils"; import { Singleton } from "../common/utils";

View File

@ -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<string, string> {
path?: string; // *-route
namespace?: string;
service?: string;
account?: string;
release?: string;
repo?: string;
chart?: string;
}
export interface LensApiRequest<P = any> {
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<string, string> = {
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<void>;
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<boolean> {
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<LensApiRequest> {
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<void> {
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);
}
}

View File

@ -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;

View File

@ -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<any>) => ({
statusCode: number;
content: any;
headers: Record<string, string>;
});
}
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<SupportedFileExtension, LensApiResultContentType> = {
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"),
},
};

View File

@ -2,23 +2,22 @@
* Copyright (c) OpenLens Authors. All rights reserved. * Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable, getInjectionToken } from "@ogre-tools/injectable";
import isDevelopmentInjectable from "../../common/vars/is-development.injectable"; import { Route, Router } from "./router";
import httpProxy from "http-proxy"; import parseRequestInjectable from "./parse-request.injectable";
import { Router } from "../router";
import routePortForwardInjectable export const routeInjectionToken = getInjectionToken<Route<any>>({
from "../routes/port-forward/route-port-forward/route-port-forward.injectable"; id: "route-injection-token",
});
const routerInjectable = getInjectable({ const routerInjectable = getInjectable({
id: "router", id: "router",
instantiate: (di) => { instantiate: (di) => {
const isDevelopment = di.inject(isDevelopmentInjectable); const routes = di.injectMany(routeInjectionToken);
const proxy = isDevelopment ? httpProxy.createProxy() : undefined;
return new Router({ return new Router(routes, {
routePortForward: di.inject(routePortForwardInjectable), parseRequest: di.inject(parseRequestInjectable),
httpProxy: proxy,
}); });
}, },
}); });

View File

@ -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<RouteHandler<any>>;
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<any>),
injectionToken: routeInjectionToken,
});
di.register(injectable);
router = di.inject(routerInjectable);
});
afterEach(() => {
mockFs.restore();
});
describe("when navigating to the route", () => {
let actualPromise: Promise<boolean>;
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([]);
});
});
});
});
});

196
src/main/router/router.ts Normal file
View File

@ -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<string, string> {
path?: string; // *-route
namespace?: string;
service?: string;
account?: string;
release?: string;
repo?: string;
chart?: string;
}
export interface LensApiRequest<P = any> {
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<unknown>[], 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<boolean> {
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<LensApiRequest> {
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<TResult> {
statusCode?: number;
response?: TResult;
error?: any;
contentType?: LensApiResultContentType;
headers?: { [name: string]: string };
proxy?: httpProxy;
}
export type RouteHandler<TResponse> = (
request: LensApiRequest
) =>
| Promise<LensApiResult<TResponse>>
| Promise<void>
| LensApiResult<TResponse>
| void;
export interface Route<TResponse> {
path: string;
method: "get" | "post" | "put" | "patch" | "delete";
handler: RouteHandler<TResponse>;
}
const handleRoute = (route: Route<unknown>) => async (request: LensApiRequest, response: http.ServerResponse) => {
let result: LensApiResult<any> | 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<string>(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();
}
};

View File

@ -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);
}
}
}

View File

@ -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<GetChartResponse> => ({
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;

View File

@ -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<string> => ({
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;

View File

@ -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<any> => ({
method: "get",
path: `${apiPrefix}/v2/charts`,
handler: async () => ({
response: await helmService.listCharts(),
}),
}),
injectionToken: routeInjectionToken,
});
export default listChartsRouteInjectable;

View File

@ -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<string> => ({
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;

View File

@ -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<any> => ({
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;

View File

@ -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<any> => ({
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;

View File

@ -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<string> => ({
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;

View File

@ -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<InstallChartResponse> => ({
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;

View File

@ -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<Record<string, any>> => ({
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;

View File

@ -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<void> => ({
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;

View File

@ -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<UpdateReleaseResponse> => ({
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;

View File

@ -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";

View File

@ -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);
}
}

View File

@ -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<ReturnType<typeof generateKubeConfig>> => ({
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,
};
}

View File

@ -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<string, string>): Promise<any[]> {
const queries = promQueries.map(p => p.trim());
const loaders = new Map<string, Promise<any>>();
async function loadMetric(query: string): Promise<any> {
async function loadMetricHelper(): Promise<any> {
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<Record<string, string>>(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);
}
}

View File

@ -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<string, string>): Promise<any[]> {
const queries = promQueries.map(p => p.trim());
const loaders = new Map<string, Promise<any>>();
async function loadMetric(query: string): Promise<any> {
async function loadMetricHelper(): Promise<any> {
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<Record<string, string>>(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<any> => ({
method: "post",
path: `${apiPrefix}/metrics`,
handler: addMetricsRoute,
}),
injectionToken: routeInjectionToken,
});
export default addMetricsRouteInjectable;

View File

@ -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<MetricProviderInfo[]> => ({
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;

View File

@ -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;
};

View File

@ -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);
}
}
}

View File

@ -4,7 +4,7 @@
*/ */
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import { PortForward, PortForwardArgs } from "./port-forward"; import { PortForward, PortForwardArgs } from "./port-forward";
import bundledKubectlInjectable from "../../kubectl/bundled-kubectl.injectable"; import bundledKubectlInjectable from "../../../kubectl/bundled-kubectl.injectable";
const createPortForwardInjectable = getInjectable({ const createPortForwardInjectable = getInjectable({
id: "create-port-forward", id: "create-port-forward",

View File

@ -2,8 +2,8 @@
* Copyright (c) OpenLens Authors. All rights reserved. * Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import logger from "../../logger"; import logger from "../../../logger";
import { getPortFrom } from "../../utils/get-port"; import { getPortFrom } from "../../../utils/get-port";
import { spawn, ChildProcessWithoutNullStreams } from "child_process"; import { spawn, ChildProcessWithoutNullStreams } from "child_process";
import * as tcpPortUsed from "tcp-port-used"; import * as tcpPortUsed from "tcp-port-used";

View File

@ -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;

View File

@ -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;

View File

@ -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,
);
}
};

View File

@ -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;

View File

@ -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;

View File

@ -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);
}
}
}

View File

@ -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<string> => ({
method: "post",
path: `${apiPrefix}/stack`,
handler: async ({ cluster, payload }) => ({
response: await new ResourceApplier(cluster).apply(payload),
}),
}),
injectionToken: routeInjectionToken,
});
export default applyResourceRouteInjectable;

View File

@ -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<string> => ({
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;

View File

@ -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<Buffer>;
}
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<Buffer> => {
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;

View File

@ -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);
}
}

View File

@ -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;

View File

@ -9047,6 +9047,11 @@ merge2@^1.3.0:
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== 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: methods@~1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" 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" resolved "https://registry.yarnpkg.com/mock-fs/-/mock-fs-5.1.2.tgz#6fa486e06d00f8793a8d2228de980eff93ce6db7"
integrity sha512-YkjQkdLulFrz0vD4BfNQdQRVmgycXTV7ykuHMlyv+C8WCHazpkiQRDthwa02kSyo8wKnY9wRptHfQLgmf0eR+A== 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: moment-timezone@^0.5.34:
version "0.5.34" version "0.5.34"
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.34.tgz#a75938f7476b88f155d3504a9343f7519d9a405c" resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.34.tgz#a75938f7476b88f155d3504a9343f7519d9a405c"