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

Merge branch 'master' into menu-styles-fix

This commit is contained in:
Alex Andreev 2021-11-16 15:54:25 +03:00
commit 48b730c169
171 changed files with 2149 additions and 1237 deletions

View File

@ -15,7 +15,7 @@ categories:
- 'area/tests' - 'area/tests'
- 'dependencies' - 'dependencies'
- 'area/documentation' - 'area/documentation'
change-template: '* #$NUMBER @$AUTHOR'
template: | template: |
## Changes since $PREVIOUS_TAG ## Changes since $PREVIOUS_TAG

14
.github/workflows/stale.yml vendored Normal file
View File

@ -0,0 +1,14 @@
name: Cull Stale Issues
on:
schedule:
- cron: "30 1 * * *"
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@4.0.0
with:
only-issue-labels: "needs-information"
repo-token: "${{ secrets.GITHUB_TOKEN }}"
# -1 here means that PRs will never be marked as stale
days-before-pr-stale: -1

5
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

73
.idea/codeStyles/Project.xml generated Normal file
View File

@ -0,0 +1,73 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="AUTODETECT_INDENTS" value="false" />
<option name="RIGHT_MARGIN" value="100" />
<HTMLCodeStyleSettings>
<option name="HTML_ATTRIBUTE_WRAP" value="4" />
<option name="HTML_TEXT_WRAP" value="0" />
<option name="HTML_KEEP_BLANK_LINES" value="1" />
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
<option name="HTML_DO_NOT_INDENT_CHILDREN_OF" value="" />
<option name="HTML_ENFORCE_QUOTES" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="OBJECT_LITERAL_WRAP" value="2" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
<option name="IMPORT_USE_NODE_RESOLUTION" value="FALSE" />
</JSCodeStyleSettings>
<MarkdownNavigatorCodeStyleSettings>
<option name="RIGHT_MARGIN" value="72" />
</MarkdownNavigatorCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<codeStyleSettings language="HTML">
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="KEEP_FIRST_COLUMN_COMMENT" value="false" />
<option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
<option name="CATCH_ON_NEW_LINE" value="true" />
<option name="FINALLY_ON_NEW_LINE" value="true" />
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
<option name="ALIGN_MULTILINE_FOR" value="false" />
<option name="CALL_PARAMETERS_WRAP" value="5" />
<option name="PREFER_PARAMETERS_WRAP" value="true" />
<option name="METHOD_PARAMETERS_WRAP" value="5" />
<option name="METHOD_PARAMETERS_LPAREN_ON_NEXT_LINE" value="true" />
<option name="METHOD_PARAMETERS_RPAREN_ON_NEXT_LINE" value="true" />
<option name="METHOD_CALL_CHAIN_WRAP" value="5" />
<option name="BINARY_OPERATION_SIGN_ON_NEXT_LINE" value="true" />
<option name="TERNARY_OPERATION_WRAP" value="5" />
<option name="TERNARY_OPERATION_SIGNS_ON_NEXT_LINE" value="true" />
<option name="ARRAY_INITIALIZER_WRAP" value="5" />
<option name="ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE" value="true" />
<option name="ARRAY_INITIALIZER_RBRACE_ON_NEXT_LINE" value="true" />
<option name="ASSIGNMENT_WRAP" value="5" />
<option name="IF_BRACE_FORCE" value="3" />
<option name="DOWHILE_BRACE_FORCE" value="3" />
<option name="WHILE_BRACE_FORCE" value="3" />
<option name="FOR_BRACE_FORCE" value="3" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/encodings.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="PROJECT" charset="UTF-8" />
</component>
</project>

View File

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

6
.idea/jsLinters/eslint.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EslintConfiguration">
<option name="fix-on-save" value="true" />
</component>
</project>

19
.idea/lens.iml generated Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
<excludeFolder url="file://$MODULE_DIR$/node_modules" />
<excludeFolder url="file://$MODULE_DIR$/extensions/kube-object-event-status/node_modules" />
<excludeFolder url="file://$MODULE_DIR$/extensions/metrics-cluster-feature/node_modules" />
<excludeFolder url="file://$MODULE_DIR$/extensions/node-menu/node_modules" />
<excludeFolder url="file://$MODULE_DIR$/extensions/pod-menu/node_modules" />
<excludeFolder url="file://$MODULE_DIR$/static/build" />
<excludeFolder url="file://$MODULE_DIR$/src/extensions/npm/extensions/dist" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/misc.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="WebPackConfiguration">
<option name="mode" value="DISABLED" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/lens.iml" filepath="$PROJECT_DIR$/.idea/lens.iml" />
</modules>
</component>
</project>

6
.idea/prettier.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myRunOnReformat" value="true" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

14
.idea/webResources.xml generated Normal file
View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="WebResourcesPaths">
<contentEntries>
<entry url="file://$PROJECT_DIR$">
<entryData>
<resourceRoots>
<path value="file://$PROJECT_DIR$" />
</resourceRoots>
</entryData>
</entry>
</contentEntries>
</component>
</project>

47
build/build_theme_vars.ts Normal file
View File

@ -0,0 +1,47 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import fs from "fs-extra";
import path from "path";
import defaultBaseLensTheme from "../src/renderer/themes/lens-dark.json";
const outputCssFile = path.resolve("src/renderer/themes/theme-vars.css");
const banner = `/*
Generated Lens theme CSS-variables, don't edit manually.
To refresh file run $: yarn run ts-node build/${path.basename(__filename)}
*/`;
const themeCssVars = Object.entries(defaultBaseLensTheme.colors)
.map(([varName, value]) => `--${varName}: ${value};`);
const content = `
${banner}
:root {
${themeCssVars.join("\n")}
}
`;
// Run
console.info(`"Saving default Lens theme css-variables to "${outputCssFile}""`);
fs.ensureFileSync(outputCssFile);
fs.writeFile(outputCssFile, content);

View File

@ -61,14 +61,14 @@ export class MetricsSettings extends React.Component<Props> {
persistence: { persistence: {
enabled: false, enabled: false,
storageClass: null, storageClass: null,
size: "20G", size: "20GiB",
}, },
nodeExporter: { nodeExporter: {
enabled: false, enabled: false,
}, },
retention: { retention: {
time: "2d", time: "2d",
size: "5GB", size: "5GiB",
}, },
kubeStateMetrics: { kubeStateMetrics: {
enabled: false, enabled: false,

View File

@ -331,6 +331,14 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => {
await cleanup(); await cleanup();
}, 10*60*1000); }, 10*60*1000);
it("shows cluster context menu in sidebar", async () => {
await frame.click(`[data-testid="sidebar-cluster-dropdown"]`);
await frame.waitForSelector(`.Menu >> text="Add to Hotbar"`);
await frame.waitForSelector(`.Menu >> text="Settings"`);
await frame.waitForSelector(`.Menu >> text="Disconnect"`);
await frame.waitForSelector(`.Menu >> text="Delete"`);
});
it("should navigate around common cluster pages", async () => { it("should navigate around common cluster pages", async () => {
for (const test of commonPageTests) { for (const test of commonPageTests) {
if (isTopPageTest(test)) { if (isTopPageTest(test)) {
@ -362,6 +370,8 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => {
} }
}, 10*60*1000); }, 10*60*1000);
it("show logs and highlight the log search entries", async () => { it("show logs and highlight the log search entries", async () => {
await frame.click(`a[href="/workloads"]`); await frame.click(`a[href="/workloads"]`);
await frame.click(`a[href="/pods"]`); await frame.click(`a[href="/pods"]`);

View File

@ -3,7 +3,7 @@
"productName": "OpenLens", "productName": "OpenLens",
"description": "OpenLens - Open Source IDE for Kubernetes", "description": "OpenLens - Open Source IDE for Kubernetes",
"homepage": "https://github.com/lensapp/lens", "homepage": "https://github.com/lensapp/lens",
"version": "5.3.0-alpha.9", "version": "5.3.0-alpha.11",
"main": "static/build/main.js", "main": "static/build/main.js",
"copyright": "© 2021 OpenLens Authors", "copyright": "© 2021 OpenLens Authors",
"license": "MIT", "license": "MIT",
@ -35,6 +35,7 @@
"download:kubectl": "yarn run ts-node build/download_kubectl.ts", "download:kubectl": "yarn run ts-node build/download_kubectl.ts",
"download:helm": "yarn run ts-node build/download_helm.ts", "download:helm": "yarn run ts-node build/download_helm.ts",
"build:tray-icons": "yarn run ts-node build/build_tray_icon.ts", "build:tray-icons": "yarn run ts-node build/build_tray_icon.ts",
"build:theme-vars": "yarn run ts-node build/build_theme_vars.ts",
"lint": "PROD=true yarn run eslint --ext js,ts,tsx --max-warnings=0 .", "lint": "PROD=true yarn run eslint --ext js,ts,tsx --max-warnings=0 .",
"lint:fix": "yarn run lint --fix", "lint:fix": "yarn run lint --fix",
"mkdocs-serve-local": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -it -p 8000:8000 -v ${PWD}:/docs mkdocs-serve-local:latest", "mkdocs-serve-local": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -it -p 8000:8000 -v ${PWD}:/docs mkdocs-serve-local:latest",
@ -220,7 +221,7 @@
"moment-timezone": "^0.5.33", "moment-timezone": "^0.5.33",
"monaco-editor": "^0.29.1", "monaco-editor": "^0.29.1",
"monaco-editor-webpack-plugin": "^5.0.0", "monaco-editor-webpack-plugin": "^5.0.0",
"node-fetch": "^2.6.6", "node-fetch": "lensapp/node-fetch#2.x",
"node-pty": "^0.10.1", "node-pty": "^0.10.1",
"npm": "^6.14.15", "npm": "^6.14.15",
"p-limit": "^3.1.0", "p-limit": "^3.1.0",
@ -278,13 +279,13 @@
"@types/md5-file": "^4.0.2", "@types/md5-file": "^4.0.2",
"@types/mini-css-extract-plugin": "^0.9.1", "@types/mini-css-extract-plugin": "^0.9.1",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "14.17.27", "@types/node": "14.17.33",
"@types/node-fetch": "^2.5.12", "@types/node-fetch": "^2.5.12",
"@types/npm": "^2.0.32", "@types/npm": "^2.0.32",
"@types/progress-bar-webpack-plugin": "^2.1.2", "@types/progress-bar-webpack-plugin": "^2.1.2",
"@types/proper-lockfile": "^4.1.2", "@types/proper-lockfile": "^4.1.2",
"@types/randomcolor": "^0.5.6", "@types/randomcolor": "^0.5.6",
"@types/react": "^17.0.33", "@types/react": "^17.0.34",
"@types/react-beautiful-dnd": "^13.1.2", "@types/react-beautiful-dnd": "^13.1.2",
"@types/react-dom": "^17.0.9", "@types/react-dom": "^17.0.9",
"@types/react-router-dom": "^5.3.2", "@types/react-router-dom": "^5.3.2",
@ -361,7 +362,7 @@
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"sharp": "^0.29.2", "sharp": "^0.29.2",
"style-loader": "^2.0.0", "style-loader": "^2.0.0",
"tailwindcss": "^2.2.17", "tailwindcss": "^2.2.19",
"ts-jest": "26.5.6", "ts-jest": "26.5.6",
"ts-loader": "^7.0.5", "ts-loader": "^7.0.5",
"ts-node": "^10.4.0", "ts-node": "^10.4.0",

View File

@ -26,7 +26,7 @@ import { Cluster } from "../main/cluster";
import migrations from "../migrations/cluster-store"; import migrations from "../migrations/cluster-store";
import logger from "../main/logger"; import logger from "../main/logger";
import { appEventBus } from "./event-bus"; import { appEventBus } from "./event-bus";
import { ipcMainHandle, ipcMainOn, ipcRendererOn, requestMain } from "./ipc"; import { ipcMainHandle, requestMain } from "./ipc";
import { disposer, toJS } from "./utils"; import { disposer, toJS } from "./utils";
import type { ClusterModel, ClusterId, ClusterState } from "./cluster-types"; import type { ClusterModel, ClusterId, ClusterState } from "./cluster-types";
@ -37,8 +37,6 @@ export interface ClusterStoreModel {
const initialStates = "cluster:states"; const initialStates = "cluster:states";
export class ClusterStore extends BaseStore<ClusterStoreModel> { export class ClusterStore extends BaseStore<ClusterStoreModel> {
private static StateChannel = "cluster:state";
clusters = observable.map<ClusterId, Cluster>(); clusters = observable.map<ClusterId, Cluster>();
protected disposer = disposer(); protected disposer = disposer();
@ -83,21 +81,13 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
} }
} }
handleStateChange = (event: any, clusterId: string, state: ClusterState) => {
logger.silly(`[CLUSTER-STORE]: received push-state at ${location.host} (${webFrame.routingId})`, clusterId, state);
this.getById(clusterId)?.setState(state);
};
registerIpcListener() { registerIpcListener() {
logger.info(`[CLUSTER-STORE] start to listen (${webFrame.routingId})`); logger.info(`[CLUSTER-STORE] start to listen (${webFrame.routingId})`);
const ipc = ipcMain ?? ipcRenderer;
if (ipcMain) { ipc?.on("cluster:state", (event, clusterId: ClusterId, state: ClusterState) => {
this.disposer.push(ipcMainOn(ClusterStore.StateChannel, this.handleStateChange)); this.getById(clusterId)?.setState(state);
} });
if (ipcRenderer) {
this.disposer.push(ipcRendererOn(ClusterStore.StateChannel, this.handleStateChange));
}
} }
unregisterIpcListener() { unregisterIpcListener() {

View File

@ -122,6 +122,14 @@ export enum ClusterStatus {
Offline = 0, Offline = 0,
} }
/**
* The message format for the "cluster:<cluster-id>:connection-update" channels
*/
export interface KubeAuthUpdate {
message: string;
isError: boolean;
}
/** /**
* The OpenLens known static metadata keys * The OpenLens known static metadata keys
*/ */
@ -173,7 +181,6 @@ export interface ClusterState {
disconnected: boolean; disconnected: boolean;
accessible: boolean; accessible: boolean;
ready: boolean; ready: boolean;
failureReason: string;
isAdmin: boolean; isAdmin: boolean;
allowedNamespaces: string[] allowedNamespaces: string[]
allowedResources: string[] allowedResources: string[]

View File

@ -51,7 +51,7 @@ describe("Crds", () => {
expect(() => crd.getVersion()).toThrowError("Failed to find a version for CustomResourceDefinition foo"); expect(() => crd.getVersion()).toThrowError("Failed to find a version for CustomResourceDefinition foo");
}); });
it("should should get the version that is both served and stored", () => { it("should get the version that is both served and stored", () => {
const crd = new CustomResourceDefinition({ const crd = new CustomResourceDefinition({
apiVersion: "apiextensions.k8s.io/v1", apiVersion: "apiextensions.k8s.io/v1",
kind: "CustomResourceDefinition", kind: "CustomResourceDefinition",
@ -79,7 +79,35 @@ describe("Crds", () => {
expect(crd.getVersion()).toBe("123"); expect(crd.getVersion()).toBe("123");
}); });
it("should should get the version that is both served and stored even with version field", () => { it("should get the version that is only stored", () => {
const crd = new CustomResourceDefinition({
apiVersion: "apiextensions.k8s.io/v1",
kind: "CustomResourceDefinition",
metadata: {
name: "foo",
resourceVersion: "12345",
uid: "12345",
},
spec: {
versions: [
{
name: "123",
served: false,
storage: true,
},
{
name: "1234",
served: false,
storage: false,
},
],
},
});
expect(crd.getVersion()).toBe("123");
});
it("should get the version that is both served and stored even with version field", () => {
const crd = new CustomResourceDefinition({ const crd = new CustomResourceDefinition({
apiVersion: "apiextensions.k8s.io/v1", apiVersion: "apiextensions.k8s.io/v1",
kind: "CustomResourceDefinition", kind: "CustomResourceDefinition",

View File

@ -19,13 +19,21 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { Pod } from "../endpoints/pods.api"; import type { Request } from "node-fetch";
import { forRemoteCluster, KubeApi } from "../kube-api"; import { forRemoteCluster, KubeApi } from "../kube-api";
import { KubeJsonApi } from "../kube-json-api"; import { KubeJsonApi } from "../kube-json-api";
import { KubeObject } from "../kube-object"; import { KubeObject } from "../kube-object";
class TestKubeObject extends KubeObject {
static kind = "Pod";
static namespaced = true;
static apiBase = "/api/v1/pods";
}
class TestKubeApi extends KubeApi<TestKubeObject> {}
describe("forRemoteCluster", () => { describe("forRemoteCluster", () => {
it("builds api client", async (done) => { it("builds api client for KubeObject", async () => {
const api = forRemoteCluster({ const api = forRemoteCluster({
cluster: { cluster: {
server: "https://127.0.0.1:6443", server: "https://127.0.0.1:6443",
@ -33,18 +41,44 @@ describe("forRemoteCluster", () => {
user: { user: {
token: "daa", token: "daa",
}, },
}, Pod); }, TestKubeObject);
expect(api).toBeInstanceOf(KubeApi);
});
it("builds api client for given KubeApi", async () => {
const api = forRemoteCluster({
cluster: {
server: "https://127.0.0.1:6443",
},
user: {
token: "daa",
},
}, TestKubeObject, TestKubeApi);
expect(api).toBeInstanceOf(TestKubeApi);
});
it("calls right api endpoint", async () => {
const api = forRemoteCluster({
cluster: {
server: "https://127.0.0.1:6443",
},
user: {
token: "daa",
},
}, TestKubeObject);
(fetch as any).mockResponse(async (request: any) => { (fetch as any).mockResponse(async (request: any) => {
expect(request.url).toEqual("https://127.0.0.1:6443/api/v1/pods"); expect(request.url).toEqual("https://127.0.0.1:6443/api/v1/pods");
done();
return { return {
body: "", body: "hello",
}; };
}); });
expect.hasAssertions();
await api.list(); await api.list();
}); });
}); });
@ -141,4 +175,122 @@ describe("KubeApi", () => {
expect(kubeApi.apiPrefix).toEqual("/apis"); expect(kubeApi.apiPrefix).toEqual("/apis");
expect(kubeApi.apiGroup).toEqual("extensions"); expect(kubeApi.apiGroup).toEqual("extensions");
}); });
describe("patch", () => {
let api: TestKubeApi;
beforeEach(() => {
api = new TestKubeApi({
request,
objectConstructor: TestKubeObject,
});
});
it("sends strategic patch by default", async () => {
expect.hasAssertions();
(fetch as any).mockResponse(async (request: Request) => {
expect(request.method).toEqual("PATCH");
expect(request.headers.get("content-type")).toMatch("strategic-merge-patch");
expect(request.body.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }}));
return {};
});
await api.patch({ name: "test", namespace: "default" }, {
spec: { replicas: 2 },
});
});
it("allows to use merge patch", async () => {
expect.hasAssertions();
(fetch as any).mockResponse(async (request: Request) => {
expect(request.method).toEqual("PATCH");
expect(request.headers.get("content-type")).toMatch("merge-patch");
expect(request.body.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }}));
return {};
});
await api.patch({ name: "test", namespace: "default" }, {
spec: { replicas: 2 },
}, "merge");
});
it("allows to use json patch", async () => {
expect.hasAssertions();
(fetch as any).mockResponse(async (request: Request) => {
expect(request.method).toEqual("PATCH");
expect(request.headers.get("content-type")).toMatch("json-patch");
expect(request.body.toString()).toEqual(JSON.stringify([{ op: "replace", path: "/spec/replicas", value: 2 }]));
return {};
});
await api.patch({ name: "test", namespace: "default" }, [
{ op: "replace", path: "/spec/replicas", value: 2 },
], "json");
});
});
describe("delete", () => {
let api: TestKubeApi;
beforeEach(() => {
api = new TestKubeApi({
request,
objectConstructor: TestKubeObject,
});
});
it("sends correct request with empty namespace", async () => {
expect.hasAssertions();
(fetch as any).mockResponse(async (request: Request) => {
expect(request.method).toEqual("DELETE");
expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/pods/foo?propagationPolicy=Background");
return {};
});
await api.delete({ name: "foo", namespace: "" });
});
it("sends correct request without namespace", async () => {
expect.hasAssertions();
(fetch as any).mockResponse(async (request: Request) => {
expect(request.method).toEqual("DELETE");
expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background");
return {};
});
await api.delete({ name: "foo" });
});
it("sends correct request with namespace", async () => {
expect.hasAssertions();
(fetch as any).mockResponse(async (request: Request) => {
expect(request.method).toEqual("DELETE");
expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods/foo?propagationPolicy=Background");
return {};
});
await api.delete({ name: "foo", namespace: "kube-system" });
});
it("allows to change propagationPolicy", async () => {
expect.hasAssertions();
(fetch as any).mockResponse(async (request: Request) => {
expect(request.method).toEqual("DELETE");
expect(request.url).toMatch("propagationPolicy=Orphan");
return {};
});
await api.delete({ name: "foo", namespace: "default", propagationPolicy: "Orphan" });
});
});
}); });

View File

@ -153,11 +153,7 @@ export class CustomResourceDefinition extends KubeObject {
// Prefer the modern `versions` over the legacy `version` // Prefer the modern `versions` over the legacy `version`
if (this.spec.versions) { if (this.spec.versions) {
for (const version of this.spec.versions) { for (const version of this.spec.versions) {
/** if (version.storage) {
* If the version is not served then 404 errors will occur
* We should also prefer the storage version
*/
if (version.served && version.storage) {
return version; return version;
} }
} }

View File

@ -25,6 +25,7 @@ import { IMetrics, metricsApi } from "./metrics.api";
import { KubeApi } from "../kube-api"; import { KubeApi } from "../kube-api";
import type { KubeJsonApiData } from "../kube-json-api"; import type { KubeJsonApiData } from "../kube-json-api";
import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing";
import type { RequireExactlyOne } from "type-fest";
export class IngressApi extends KubeApi<Ingress> { export class IngressApi extends KubeApi<Ingress> {
} }
@ -58,7 +59,7 @@ export interface ILoadBalancerIngress {
// extensions/v1beta1 // extensions/v1beta1
interface IExtensionsBackend { interface IExtensionsBackend {
serviceName: string; serviceName: string;
servicePort: number; servicePort: number | string;
} }
// networking.k8s.io/v1 // networking.k8s.io/v1
@ -101,14 +102,18 @@ export interface Ingress {
}[]; }[];
// extensions/v1beta1 // extensions/v1beta1
backend?: IExtensionsBackend; backend?: IExtensionsBackend;
// networking.k8s.io/v1 /**
defaultBackend?: INetworkingBackend & { * The default backend which is exactly on of:
* - service
* - resource
*/
defaultBackend?: RequireExactlyOne<INetworkingBackend & {
resource: { resource: {
apiGroup: string; apiGroup: string;
kind: string; kind: string;
name: string; name: string;
} }
} }>
}; };
status: { status: {
loadBalancer: { loadBalancer: {
@ -153,10 +158,11 @@ export class Ingress extends KubeObject {
return routes; return routes;
} }
getServiceNamePort() { getServiceNamePort(): IExtensionsBackend {
const { spec } = this; const { spec: { backend, defaultBackend } = {}} = this;
const serviceName = spec?.defaultBackend?.service.name ?? spec?.backend?.serviceName;
const servicePort = spec?.defaultBackend?.service.port.number ?? spec?.defaultBackend?.service.port.name ?? spec?.backend?.servicePort; const serviceName = defaultBackend?.service?.name ?? backend?.serviceName;
const servicePort = defaultBackend?.service?.port.number ?? defaultBackend?.service?.port.name ?? backend?.servicePort;
return { return {
serviceName, serviceName,

View File

@ -24,7 +24,6 @@ import { autoBind } from "../../utils";
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
import { KubeApi } from "../kube-api"; import { KubeApi } from "../kube-api";
import { metricsApi } from "./metrics.api"; import { metricsApi } from "./metrics.api";
import type { JsonApiParams } from "../json-api";
import type { KubeJsonApiData } from "../kube-json-api"; import type { KubeJsonApiData } from "../kube-json-api";
import type { IPodContainer, IPodMetrics } from "./pods.api"; import type { IPodContainer, IPodMetrics } from "./pods.api";
import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing";
@ -121,14 +120,6 @@ export class Job extends WorkloadKubeObject {
return [...containers].map(container => container.image); return [...containers].map(container => container.image);
} }
delete() {
const params: JsonApiParams = {
query: { propagationPolicy: "Background" },
};
return super.delete(params);
}
} }
export class JobApi extends KubeApi<Job> { export class JobApi extends KubeApi<Job> {

View File

@ -236,13 +236,10 @@ export class Node extends KubeObject {
} }
getOperatingSystem(): string { getOperatingSystem(): string {
const label = this.getLabels().find(label => label.startsWith("kubernetes.io/os=")); return this.metadata?.labels?.["kubernetes.io/os"]
|| this.metadata?.labels?.["beta.kubernetes.io/os"]
if (label) { || this.status?.nodeInfo?.operatingSystem
return label.split("=", 2)[1]; || "linux";
}
return "linux";
} }
isUnschedulable() { isUnschedulable() {

View File

@ -165,40 +165,40 @@ interface IContainerProbe {
failureThreshold?: number; failureThreshold?: number;
} }
export interface ContainerStateRunning {
startedAt: string;
}
export interface ContainerStateWaiting {
reason: string;
message: string;
}
export interface ContainerStateTerminated {
startedAt: string;
finishedAt: string;
exitCode: number;
reason: string;
containerID?: string;
message?: string;
signal?: number;
}
/**
* ContainerState holds a possible state of container. Only one of its members
* may be specified. If none of them is specified, the default one is
* `ContainerStateWaiting`.
*/
export interface ContainerState {
running?: ContainerStateRunning;
waiting?: ContainerStateWaiting;
terminated?: ContainerStateTerminated;
}
export interface IPodContainerStatus { export interface IPodContainerStatus {
name: string; name: string;
state?: { state?: ContainerState;
[index: string]: object; lastState?: ContainerState;
running?: {
startedAt: string;
};
waiting?: {
reason: string;
message: string;
};
terminated?: {
startedAt: string;
finishedAt: string;
exitCode: number;
reason: string;
};
};
lastState?: {
[index: string]: object;
running?: {
startedAt: string;
};
waiting?: {
reason: string;
message: string;
};
terminated?: {
startedAt: string;
finishedAt: string;
exitCode: number;
reason: string;
};
};
ready: boolean; ready: boolean;
restartCount: number; restartCount: number;
image: string; image: string;
@ -373,23 +373,16 @@ export class Pod extends WorkloadKubeObject {
} }
// Returns pod phase or container error if occurred // Returns pod phase or container error if occurred
getStatusMessage() { getStatusMessage(): string {
if (this.getReason() === PodStatus.EVICTED) return "Evicted"; if (this.getReason() === PodStatus.EVICTED) {
if (this.metadata.deletionTimestamp) return "Terminating"; return "Evicted";
const statuses = this.getContainerStatuses(false); // not including initContainers
for (const { state } of statuses.reverse()) {
if (state.waiting) {
return state.waiting.reason || "Waiting";
}
if (state.terminated) {
return state.terminated.reason || "Terminated";
}
} }
return this.getStatusPhase(); if (this.metadata.deletionTimestamp) {
return "Terminating";
}
return this.getStatusPhase() || "Waiting";
} }
getStatusPhase() { getStatusPhase() {

View File

@ -37,6 +37,7 @@ import { noop } from "../utils";
import type { RequestInit } from "node-fetch"; import type { RequestInit } from "node-fetch";
import AbortController from "abort-controller"; import AbortController from "abort-controller";
import { Agent, AgentOptions } from "https"; import { Agent, AgentOptions } from "https";
import type { Patch } from "rfc6902";
export interface IKubeApiOptions<T extends KubeObject> { export interface IKubeApiOptions<T extends KubeObject> {
/** /**
@ -97,6 +98,8 @@ export interface ILocalKubeApiConfig {
} }
} }
export type PropagationPolicy = undefined | "Orphan" | "Foreground" | "Background";
/** /**
* @deprecated * @deprecated
*/ */
@ -115,7 +118,7 @@ export interface IRemoteKubeApiConfig {
} }
} }
export function forCluster<T extends KubeObject>(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor<T>): KubeApi<T> { export function forCluster<T extends KubeObject, Y extends KubeApi<T> = KubeApi<T>>(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor<T>, apiClass: new (apiOpts: IKubeApiOptions<T>) => Y = null): KubeApi<T> {
const url = new URL(apiBase.config.serverAddress); const url = new URL(apiBase.config.serverAddress);
const request = new KubeJsonApi({ const request = new KubeJsonApi({
serverAddress: apiBase.config.serverAddress, serverAddress: apiBase.config.serverAddress,
@ -127,15 +130,18 @@ export function forCluster<T extends KubeObject>(cluster: ILocalKubeApiConfig, k
}, },
}); });
return new KubeApi({ if (!apiClass) {
apiClass = KubeApi as new (apiOpts: IKubeApiOptions<T>) => Y;
}
return new apiClass({
objectConstructor: kubeClass, objectConstructor: kubeClass,
request, request,
}); });
} }
export function forRemoteCluster<T extends KubeObject>(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor<T>): KubeApi<T> { export function forRemoteCluster<T extends KubeObject, Y extends KubeApi<T> = KubeApi<T>>(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor<T>, apiClass: new (apiOpts: IKubeApiOptions<T>) => Y = null): Y {
const reqInit: RequestInit = {}; const reqInit: RequestInit = {};
const agentOptions: AgentOptions = {}; const agentOptions: AgentOptions = {};
if (config.cluster.skipTLSVerify === true) { if (config.cluster.skipTLSVerify === true) {
@ -172,8 +178,12 @@ export function forRemoteCluster<T extends KubeObject>(config: IRemoteKubeApiCon
} : {}), } : {}),
}, reqInit); }, reqInit);
return new KubeApi({ if (!apiClass) {
objectConstructor: kubeClass, apiClass = KubeApi as new (apiOpts: IKubeApiOptions<T>) => Y;
}
return new apiClass({
objectConstructor: kubeClass as KubeObjectConstructor<T>,
request, request,
}); });
} }
@ -200,6 +210,14 @@ export type KubeApiWatchOptions = {
retry?: boolean; retry?: boolean;
}; };
export type KubeApiPatchType = "merge" | "json" | "strategic";
const patchTypeHeaders: Record<KubeApiPatchType, string> = {
"merge": "application/merge-patch+json",
"json": "application/json-patch+json",
"strategic": "application/strategic-merge-patch+json",
};
export class KubeApi<T extends KubeObject> { export class KubeApi<T extends KubeObject> {
readonly kind: string; readonly kind: string;
readonly apiBase: string; readonly apiBase: string;
@ -224,10 +242,7 @@ export class KubeApi<T extends KubeObject> {
isNamespaced = options.objectConstructor?.namespaced, isNamespaced = options.objectConstructor?.namespaced,
} = options || {}; } = options || {};
if (!options.apiBase) { const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = parseKubeApi(options.apiBase || objectConstructor.apiBase);
options.apiBase = objectConstructor.apiBase;
}
const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = parseKubeApi(options.apiBase);
this.kind = kind; this.kind = kind;
this.isNamespaced = isNamespaced; this.isNamespaced = isNamespaced;
@ -471,11 +486,34 @@ export class KubeApi<T extends KubeObject> {
return parsed; return parsed;
} }
async delete({ name = "", namespace = "default" }) { async patch({ name = "", namespace = "default" } = {}, data?: Partial<T> | Patch, strategy: KubeApiPatchType = "strategic"): Promise<T | null> {
await this.checkPreferredVersion(); await this.checkPreferredVersion();
const apiUrl = this.getUrl({ namespace, name }); const apiUrl = this.getUrl({ namespace, name });
return this.request.del(apiUrl); const res = await this.request.patch(apiUrl, { data }, {
headers: {
"content-type": patchTypeHeaders[strategy],
},
});
const parsed = this.parseResponse(res);
if (Array.isArray(parsed)) {
throw new Error(`PATCH request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`);
}
return parsed;
}
async delete({ name = "", namespace = "default", propagationPolicy = "Background" }: { name: string, namespace?: string, propagationPolicy?: PropagationPolicy }) {
await this.checkPreferredVersion();
const apiUrl = this.getUrl({ namespace, name });
const reqInit = {
query: {
propagationPolicy,
},
};
return this.request.del(apiUrl, reqInit);
} }
getWatchUrl(namespace = "", query: IKubeApiQueryParams = {}) { getWatchUrl(namespace = "", query: IKubeApiQueryParams = {}) {

View File

@ -21,6 +21,9 @@
import { JsonApi, JsonApiData, JsonApiError } from "./json-api"; import { JsonApi, JsonApiData, JsonApiError } from "./json-api";
import type { Response } from "node-fetch"; import type { Response } from "node-fetch";
import type { Cluster } from "../../main/cluster";
import { LensProxy } from "../../main/lens-proxy";
import { apiKubePrefix, isDebugging } from "../vars";
export interface KubeJsonApiListMetadata { export interface KubeJsonApiListMetadata {
resourceVersion: string; resourceVersion: string;
@ -70,6 +73,20 @@ export interface KubeJsonApiError extends JsonApiError {
} }
export class KubeJsonApi extends JsonApi<KubeJsonApiData> { export class KubeJsonApi extends JsonApi<KubeJsonApiData> {
static forCluster(cluster: Cluster): KubeJsonApi {
const port = LensProxy.getInstance().port;
return new this({
serverAddress: `http://127.0.0.1:${port}`,
apiBase: apiKubePrefix,
debug: isDebugging,
}, {
headers: {
"Host": `${cluster.id}.localhost:${port}`,
},
});
}
protected parseError(error: KubeJsonApiError | any, res: Response): string[] { protected parseError(error: KubeJsonApiError | any, res: Response): string[] {
const { status, reason, message } = error; const { status, reason, message } = error;
@ -80,4 +97,3 @@ export class KubeJsonApi extends JsonApi<KubeJsonApiData> {
return super.parseError(error, res); return super.parseError(error, res);
} }
} }

View File

@ -295,16 +295,30 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
} }
async patch(item: T, patch: Patch): Promise<T> { async patch(item: T, patch: Patch): Promise<T> {
return this.postUpdate(await item.patch(patch)); return this.postUpdate(
await this.api.patch(
{
name: item.getName(), namespace: item.getNs(),
},
patch,
"json",
),
);
} }
async update(item: T, data: Partial<T>): Promise<T> { async update(item: T, data: Partial<T>): Promise<T> {
return this.postUpdate(await item.update(data)); return this.postUpdate(
await this.api.update(
{
name: item.getName(), namespace: item.getNs(),
},
data,
),
);
} }
async remove(item: T) { async remove(item: T) {
await item.delete(); await this.api.delete({ name: item.getName(), namespace: item.getNs() });
this.items.remove(item);
this.selectedItemsIds.delete(item.getId()); this.selectedItemsIds.delete(item.getId());
} }

View File

@ -306,6 +306,9 @@ export class KubeObject<Metadata extends KubeObjectMetadata = KubeObjectMetadata
return JSON.parse(JSON.stringify(this)); return JSON.parse(JSON.stringify(this));
} }
/**
* @deprecated use KubeApi.patch instead
*/
async patch(patch: Patch): Promise<KubeJsonApiData | null> { async patch(patch: Patch): Promise<KubeJsonApiData | null> {
for (const op of patch) { for (const op of patch) {
if (KubeObject.nonEditablePaths.has(op.path)) { if (KubeObject.nonEditablePaths.has(op.path)) {
@ -328,6 +331,8 @@ export class KubeObject<Metadata extends KubeObjectMetadata = KubeObjectMetadata
* Note: this is brittle if `data` is not actually partial (but instead whole). * Note: this is brittle if `data` is not actually partial (but instead whole).
* As fields such as `resourceVersion` will probably out of date. This is a * As fields such as `resourceVersion` will probably out of date. This is a
* common race condition. * common race condition.
*
* @deprecated use KubeApi.update instead
*/ */
async update(data: Partial<this>): Promise<KubeJsonApiData | null> { async update(data: Partial<this>): Promise<KubeJsonApiData | null> {
// use unified resource-applier api for updating all k8s objects // use unified resource-applier api for updating all k8s objects
@ -337,6 +342,9 @@ export class KubeObject<Metadata extends KubeObjectMetadata = KubeObjectMetadata
}); });
} }
/**
* @deprecated use KubeApi.delete instead
*/
delete(params?: JsonApiParams) { delete(params?: JsonApiParams) {
return apiKube.del(this.selfLink, params); return apiKube.del(this.selfLink, params);
} }

View File

@ -23,11 +23,20 @@
import * as Base64 from "crypto-js/enc-base64"; import * as Base64 from "crypto-js/enc-base64";
import * as Utf8 from "crypto-js/enc-utf8"; import * as Utf8 from "crypto-js/enc-utf8";
export const base64 = { /**
decode(data: string) { * Computes utf-8 from base64
return Base64.parse(data).toString(Utf8); * @param data A Base64 encoded string
}, * @returns The original utf-8 string
encode(data: string) { */
return Utf8.parse(data).toString(Base64); export function decode(data: string): string {
}, return Base64.parse(data).toString(Utf8);
}; }
/**
* Computes base64 from utf-8
* @param data A normal string
* @returns A base64 encoded version
*/
export function encode(data: string): string {
return Utf8.parse(data).toString(Base64);
}

View File

@ -28,7 +28,6 @@ export function noop<T extends any[]>(...args: T): void {
export * from "./app-version"; export * from "./app-version";
export * from "./autobind"; export * from "./autobind";
export * from "./base64";
export * from "./camelCase"; export * from "./camelCase";
export * from "./cloneJson"; export * from "./cloneJson";
export * from "./cluster-id-url-parsing"; export * from "./cluster-id-url-parsing";
@ -62,9 +61,11 @@ export * from "./types";
import * as iter from "./iter"; import * as iter from "./iter";
import * as array from "./array"; import * as array from "./array";
import * as tuple from "./tuple"; import * as tuple from "./tuple";
import * as base64 from "./base64";
export { export {
iter, iter,
array, array,
tuple, tuple,
base64,
}; };

View File

@ -23,7 +23,7 @@ export { isAllowedResource } from "../../common/utils/allowed-resource";
export { ResourceStack } from "../../common/k8s/resource-stack"; export { ResourceStack } from "../../common/k8s/resource-stack";
export { apiManager } from "../../common/k8s-api/api-manager"; export { apiManager } from "../../common/k8s-api/api-manager";
export { KubeApi, forCluster, forRemoteCluster } from "../../common/k8s-api/kube-api"; export { KubeApi, forCluster, forRemoteCluster } from "../../common/k8s-api/kube-api";
export { KubeObject } from "../../common/k8s-api/kube-object"; export { KubeObject, KubeStatus } from "../../common/k8s-api/kube-object";
export { KubeObjectStore } from "../../common/k8s-api/kube-object.store"; export { KubeObjectStore } from "../../common/k8s-api/kube-object.store";
export { Pod, podsApi, PodsApi } from "../../common/k8s-api/endpoints/pods.api"; export { Pod, podsApi, PodsApi } from "../../common/k8s-api/endpoints/pods.api";
export { Node, nodesApi, NodesApi } from "../../common/k8s-api/endpoints/nodes.api"; export { Node, nodesApi, NodesApi } from "../../common/k8s-api/endpoints/nodes.api";

View File

@ -24,7 +24,7 @@ export { ResourceStack } from "../../common/k8s/resource-stack";
export { apiManager } from "../../common/k8s-api/api-manager"; export { apiManager } from "../../common/k8s-api/api-manager";
export { KubeObjectStore } from "../../common/k8s-api/kube-object.store"; export { KubeObjectStore } from "../../common/k8s-api/kube-object.store";
export { KubeApi, forCluster, forRemoteCluster } from "../../common/k8s-api/kube-api"; export { KubeApi, forCluster, forRemoteCluster } from "../../common/k8s-api/kube-api";
export { KubeObject } from "../../common/k8s-api/kube-object"; export { KubeObject, KubeStatus } from "../../common/k8s-api/kube-object";
export { Pod, podsApi, PodsApi } from "../../common/k8s-api/endpoints"; export { Pod, podsApi, PodsApi } from "../../common/k8s-api/endpoints";
export { Node, nodesApi, NodesApi } from "../../common/k8s-api/endpoints"; export { Node, nodesApi, NodesApi } from "../../common/k8s-api/endpoints";
export { Deployment, deploymentApi, DeploymentApi } from "../../common/k8s-api/endpoints"; export { Deployment, deploymentApi, DeploymentApi } from "../../common/k8s-api/endpoints";

View File

@ -55,7 +55,7 @@ import { ChildProcess, spawn } from "child_process";
import { bundledKubectlPath, Kubectl } from "../kubectl"; import { bundledKubectlPath, Kubectl } from "../kubectl";
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { waitUntilUsed } from "tcp-port-used"; import { waitUntilUsed } from "tcp-port-used";
import type { Readable } from "stream"; import { EventEmitter, Readable } from "stream";
import { UserStore } from "../../common/user-store"; import { UserStore } from "../../common/user-store";
import { Console } from "console"; import { Console } from "console";
import { stdout, stderr } from "process"; import { stdout, stderr } from "process";
@ -134,30 +134,73 @@ describe("kube auth proxy tests", () => {
describe("spawn tests", () => { describe("spawn tests", () => {
let mockedCP: MockProxy<ChildProcess>; let mockedCP: MockProxy<ChildProcess>;
let listeners: Record<string, (...args: any[]) => void>; let listeners: EventEmitter;
let proxy: KubeAuthProxy; let proxy: KubeAuthProxy;
beforeEach(async () => { beforeEach(async () => {
mockedCP = mock<ChildProcess>(); mockedCP = mock<ChildProcess>();
listeners = {}; listeners = new EventEmitter();
jest.spyOn(Kubectl.prototype, "checkBinary").mockReturnValueOnce(Promise.resolve(true)); jest.spyOn(Kubectl.prototype, "checkBinary").mockReturnValueOnce(Promise.resolve(true));
jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValueOnce(Promise.resolve(false)); jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValueOnce(Promise.resolve(false));
mockedCP.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): ChildProcess => { mockedCP.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): ChildProcess => {
listeners[event] = listener; listeners.on(event, listener);
return mockedCP; return mockedCP;
}); });
mockedCP.stderr = mock<Readable>(); mockedCP.stderr = mock<Readable>();
mockedCP.stderr.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { mockedCP.stderr.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners[`stderr/${event}`] = listener; listeners.on(`stderr/${event}`, listener);
return mockedCP.stderr;
});
mockedCP.stderr.off.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.off(`stderr/${event}`, listener);
return mockedCP.stderr;
});
mockedCP.stderr.removeListener.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.off(`stderr/${event}`, listener);
return mockedCP.stderr;
});
mockedCP.stderr.once.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.once(`stderr/${event}`, listener);
return mockedCP.stderr;
});
mockedCP.stderr.removeAllListeners.mockImplementation((event?: string): Readable => {
listeners.removeAllListeners(event ?? `stderr/${event}`);
return mockedCP.stderr; return mockedCP.stderr;
}); });
mockedCP.stdout = mock<Readable>(); mockedCP.stdout = mock<Readable>();
mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners[`stdout/${event}`] = listener; listeners.on(`stdout/${event}`, listener);
listeners[`stdout/${event}`]("Starting to serve on 127.0.0.1:9191");
if (event === "data") {
listeners.emit("stdout/data", "Starting to serve on 127.0.0.1:9191");
}
return mockedCP.stdout;
});
mockedCP.stdout.once.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.once(`stdout/${event}`, listener);
return mockedCP.stdout;
});
mockedCP.stdout.off.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.off(`stdout/${event}`, listener);
return mockedCP.stdout;
});
mockedCP.stdout.removeListener.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.off(`stdout/${event}`, listener);
return mockedCP.stdout;
});
mockedCP.stdout.removeAllListeners.mockImplementation((event?: string): Readable => {
listeners.removeAllListeners(event ?? `stdout/${event}`);
return mockedCP.stdout; return mockedCP.stdout;
}); });
@ -175,36 +218,36 @@ describe("kube auth proxy tests", () => {
it("should call spawn and broadcast errors", async () => { it("should call spawn and broadcast errors", async () => {
await proxy.run(); await proxy.run();
listeners["error"]({ message: "foobarbat" }); listeners.emit("error", { message: "foobarbat" });
expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "foobarbat", error: true }); expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "foobarbat", isError: true });
}); });
it("should call spawn and broadcast exit", async () => { it("should call spawn and broadcast exit", async () => {
await proxy.run(); await proxy.run();
listeners["exit"](0); listeners.emit("exit", 0);
expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "proxy exited with code: 0", error: false }); expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "proxy exited with code: 0", isError: false });
}); });
it("should call spawn and broadcast errors from stderr", async () => { it("should call spawn and broadcast errors from stderr", async () => {
await proxy.run(); await proxy.run();
listeners["stderr/data"]("an error"); listeners.emit("stderr/data", "an error");
expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "an error", error: true }); expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "an error", isError: true });
}); });
it("should call spawn and broadcast stdout serving info", async () => { it("should call spawn and broadcast stdout serving info", async () => {
await proxy.run(); await proxy.run();
expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "Authentication proxy started\n" }); expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "Authentication proxy started", isError: false });
}); });
it("should call spawn and broadcast stdout other info", async () => { it("should call spawn and broadcast stdout other info", async () => {
await proxy.run(); await proxy.run();
listeners["stdout/data"]("some info"); listeners.emit("stdout/data", "some info");
expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "some info" }); expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "some info", isError: false });
}); });
}); });
}); });

View File

@ -20,7 +20,7 @@
*/ */
import { action, computed, IComputedValue, IObservableArray, makeObservable, observable } from "mobx"; import { action, computed, IComputedValue, IObservableArray, makeObservable, observable } from "mobx";
import { CatalogCategoryRegistry, catalogCategoryRegistry, CatalogEntity, CatalogEntityKindData } from "../../common/catalog"; import { CatalogCategoryRegistry, catalogCategoryRegistry, CatalogEntity, CatalogEntityConstructor, CatalogEntityKindData } from "../../common/catalog";
import { iter } from "../../common/utils"; import { iter } from "../../common/utils";
export class CatalogEntityRegistry { export class CatalogEntityRegistry {
@ -59,7 +59,7 @@ export class CatalogEntityRegistry {
return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind) as T[]; return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind) as T[];
} }
getItemsByEntityClass<T extends CatalogEntity>({ apiVersion, kind }: CatalogEntityKindData): T[] { getItemsByEntityClass<T extends CatalogEntity>({ apiVersion, kind }: CatalogEntityKindData & CatalogEntityConstructor<T>): T[] {
return this.getItemsForApiKind(apiVersion, kind); return this.getItemsForApiKind(apiVersion, kind);
} }
} }

View File

@ -41,6 +41,8 @@ export class ClusterManager extends Singleton {
private store = ClusterStore.getInstance(); private store = ClusterStore.getInstance();
deleting = observable.set<ClusterId>(); deleting = observable.set<ClusterId>();
@observable visibleCluster: ClusterId | undefined = undefined;
constructor() { constructor() {
super(); super();
makeObservable(this); makeObservable(this);
@ -61,8 +63,22 @@ export class ClusterManager extends Singleton {
{ fireImmediately: false }, { fireImmediately: false },
); );
reaction(() => catalogEntityRegistry.getItemsForApiKind<KubernetesCluster>("entity.k8slens.dev/v1alpha1", "KubernetesCluster"), (entities) => { reaction(
this.syncClustersFromCatalog(entities); () => catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster),
entities => this.syncClustersFromCatalog(entities),
);
reaction(() => [
catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster),
this.visibleCluster,
] as const, ([entities, visibleCluster]) => {
for (const entity of entities) {
if (entity.getId() === visibleCluster) {
entity.status.active = true;
} else {
entity.status.active = false;
}
}
}); });
observe(this.deleting, change => { observe(this.deleting, change => {
@ -240,27 +256,20 @@ export class ClusterManager extends Singleton {
} }
getClusterForRequest(req: http.IncomingMessage): Cluster { getClusterForRequest(req: http.IncomingMessage): Cluster {
let cluster: Cluster = null;
// lens-server is connecting to 127.0.0.1:<port>/<uid> // lens-server is connecting to 127.0.0.1:<port>/<uid>
if (req.headers.host.startsWith("127.0.0.1")) { if (req.headers.host.startsWith("127.0.0.1")) {
const clusterId = req.url.split("/")[1]; const clusterId = req.url.split("/")[1];
const cluster = this.store.getById(clusterId);
cluster = this.store.getById(clusterId);
if (cluster) { if (cluster) {
// we need to swap path prefix so that request is proxied to kube api // we need to swap path prefix so that request is proxied to kube api
req.url = req.url.replace(`/${clusterId}`, apiKubePrefix); req.url = req.url.replace(`/${clusterId}`, apiKubePrefix);
} }
} else if (req.headers["x-cluster-id"]) {
cluster = this.store.getById(req.headers["x-cluster-id"].toString());
} else {
const clusterId = getClusterIdFromHost(req.headers.host);
cluster = this.store.getById(clusterId); return cluster;
} }
return cluster; return this.store.getById(getClusterIdFromHost(req.headers.host));
} }
} }

View File

@ -32,7 +32,7 @@ import logger from "./logger";
import { VersionDetector } from "./cluster-detectors/version-detector"; import { VersionDetector } from "./cluster-detectors/version-detector";
import { DetectorRegistry } from "./cluster-detectors/detector-registry"; import { DetectorRegistry } from "./cluster-detectors/detector-registry";
import plimit from "p-limit"; import plimit from "p-limit";
import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel } from "../common/cluster-types"; import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel, KubeAuthUpdate } from "../common/cluster-types";
import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus } from "../common/cluster-types"; import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus } from "../common/cluster-types";
import { storedKubeConfigFolder, toJS } from "../common/utils"; import { storedKubeConfigFolder, toJS } from "../common/utils";
import type { Response } from "request"; import type { Response } from "request";
@ -117,12 +117,6 @@ export class Cluster implements ClusterModel, ClusterState {
* @observable * @observable
*/ */
@observable disconnected = true; @observable disconnected = true;
/**
* Connection failure reason
*
* @observable
*/
@observable failureReason: string;
/** /**
* Does user have admin like access * Does user have admin like access
* *
@ -358,14 +352,20 @@ export class Cluster implements ClusterModel, ClusterState {
if (this.disconnected || !this.accessible) { if (this.disconnected || !this.accessible) {
await this.reconnect(); await this.reconnect();
} }
this.broadcastConnectUpdate("Refreshing connection status ...");
await this.refreshConnectionStatus(); await this.refreshConnectionStatus();
if (this.accessible) { if (this.accessible) {
this.broadcastConnectUpdate("Refreshing cluster accessibility ...");
await this.refreshAccessibility(); await this.refreshAccessibility();
this.ensureKubectl(); // download kubectl in background, so it's not blocking dashboard // download kubectl in background, so it's not blocking dashboard
this.ensureKubectl()
.catch(error => logger.warn(`[CLUSTER]: failed to download kubectl for clusterId=${this.id}`, error));
this.broadcastConnectUpdate("Connected, waiting for view to load ...");
} }
this.activated = true;
this.activated = true;
this.pushState(); this.pushState();
} }
@ -445,9 +445,8 @@ export class Cluster implements ClusterModel, ClusterState {
private async refreshAccessibility(): Promise<void> { private async refreshAccessibility(): Promise<void> {
this.isAdmin = await this.isClusterAdmin(); this.isAdmin = await this.isClusterAdmin();
this.isGlobalWatchEnabled = await this.canUseWatchApi({ resource: "*" }); this.isGlobalWatchEnabled = await this.canUseWatchApi({ resource: "*" });
this.allowedNamespaces = await this.getAllowedNamespaces();
await this.refreshAllowedResources(); this.allowedResources = await this.getAllowedResources();
this.ready = true; this.ready = true;
} }
@ -462,15 +461,6 @@ export class Cluster implements ClusterModel, ClusterState {
this.accessible = connectionStatus == ClusterStatus.AccessGranted; this.accessible = connectionStatus == ClusterStatus.AccessGranted;
} }
/**
* @internal
*/
@action
async refreshAllowedResources() {
this.allowedNamespaces = await this.getAllowedNamespaces();
this.allowedResources = await this.getAllowedResources();
}
async getKubeconfig(): Promise<KubeConfig> { async getKubeconfig(): Promise<KubeConfig> {
const { config } = await loadConfigFromFile(this.kubeConfigPath); const { config } = await loadConfigFromFile(this.kubeConfigPath);
@ -501,34 +491,35 @@ export class Cluster implements ClusterModel, ClusterState {
this.metadata.version = versionData.value; this.metadata.version = versionData.value;
this.failureReason = null;
return ClusterStatus.AccessGranted; return ClusterStatus.AccessGranted;
} catch (error) { } catch (error) {
logger.error(`Failed to connect cluster "${this.contextName}": ${error}`); logger.error(`[CLUSTER]: Failed to connect to "${this.contextName}": ${error}`);
if (error.statusCode) { if (error.statusCode) {
if (error.statusCode >= 400 && error.statusCode < 500) { if (error.statusCode >= 400 && error.statusCode < 500) {
this.failureReason = "Invalid credentials"; this.broadcastConnectUpdate("Invalid credentials", true);
return ClusterStatus.AccessDenied;
} else {
this.failureReason = error.error || error.message;
return ClusterStatus.Offline;
}
} else if (error.failed === true) {
if (error.timedOut === true) {
this.failureReason = "Connection timed out";
return ClusterStatus.Offline;
} else {
this.failureReason = "Failed to fetch credentials";
return ClusterStatus.AccessDenied; return ClusterStatus.AccessDenied;
} }
this.broadcastConnectUpdate(error.error || error.message, true);
return ClusterStatus.Offline;
} }
this.failureReason = error.message;
if (error.failed === true) {
if (error.timedOut === true) {
this.broadcastConnectUpdate("Connection timed out", true);
return ClusterStatus.Offline;
}
this.broadcastConnectUpdate("Failed to fetch credentials", true);
return ClusterStatus.AccessDenied;
}
this.broadcastConnectUpdate(error.message, true);
return ClusterStatus.Offline; return ClusterStatus.Offline;
} }
@ -579,7 +570,7 @@ export class Cluster implements ClusterModel, ClusterState {
} }
toJSON(): ClusterModel { toJSON(): ClusterModel {
const model: ClusterModel = { return toJS({
id: this.id, id: this.id,
contextName: this.contextName, contextName: this.contextName,
kubeConfigPath: this.kubeConfigPath, kubeConfigPath: this.kubeConfigPath,
@ -589,29 +580,24 @@ export class Cluster implements ClusterModel, ClusterState {
metadata: this.metadata, metadata: this.metadata,
accessibleNamespaces: this.accessibleNamespaces, accessibleNamespaces: this.accessibleNamespaces,
labels: this.labels, labels: this.labels,
}; });
return toJS(model);
} }
/** /**
* Serializable cluster-state used for sync btw main <-> renderer * Serializable cluster-state used for sync btw main <-> renderer
*/ */
getState(): ClusterState { getState(): ClusterState {
const state: ClusterState = { return toJS({
apiUrl: this.apiUrl, apiUrl: this.apiUrl,
online: this.online, online: this.online,
ready: this.ready, ready: this.ready,
disconnected: this.disconnected, disconnected: this.disconnected,
accessible: this.accessible, accessible: this.accessible,
failureReason: this.failureReason,
isAdmin: this.isAdmin, isAdmin: this.isAdmin,
allowedNamespaces: this.allowedNamespaces, allowedNamespaces: this.allowedNamespaces,
allowedResources: this.allowedResources, allowedResources: this.allowedResources,
isGlobalWatchEnabled: this.isGlobalWatchEnabled, isGlobalWatchEnabled: this.isGlobalWatchEnabled,
}; });
return toJS(state);
} }
/** /**
@ -643,6 +629,17 @@ export class Cluster implements ClusterModel, ClusterState {
}; };
} }
/**
* broadcast an authentication update concerning this cluster
* @internal
*/
broadcastConnectUpdate(message: string, isError = false): void {
const update: KubeAuthUpdate = { message, isError };
logger.debug(`[CLUSTER]: broadcasting connection update`, { ...update, meta: this.getMeta() });
broadcastMessage(`cluster:${this.id}:connection-update`, update);
}
protected async getAllowedNamespaces() { protected async getAllowedNamespaces() {
if (this.accessibleNamespaces.length) { if (this.accessibleNamespaces.length) {
return this.accessibleNamespaces; return this.accessibleNamespaces;

View File

@ -146,7 +146,7 @@ export class ContextHandler {
proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy; proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy;
} }
this.kubeAuthProxy = new KubeAuthProxy(this.cluster, proxyEnv); this.kubeAuthProxy = new KubeAuthProxy(this.cluster, proxyEnv);
this.kubeAuthProxy.run(); await this.kubeAuthProxy.run();
} }
await this.kubeAuthProxy.whenReady; await this.kubeAuthProxy.whenReady;
@ -157,8 +157,4 @@ export class ContextHandler {
this.kubeAuthProxy = undefined; this.kubeAuthProxy = undefined;
this.apiTarget = undefined; this.apiTarget = undefined;
} }
get proxyLastError(): string {
return this.kubeAuthProxy?.lastError || "";
}
} }

View File

@ -63,8 +63,9 @@ import { ensureDir } from "fs-extra";
import { Router } from "./router"; import { Router } from "./router";
import { initMenu } from "./menu"; import { initMenu } from "./menu";
import { initTray } from "./tray"; import { initTray } from "./tray";
import { kubeApiRequest, shellApiRequest } from "./proxy-functions"; import { kubeApiRequest, shellApiRequest, ShellRequestAuthenticator } from "./proxy-functions";
import { AppPaths } from "../common/app-paths"; import { AppPaths } from "../common/app-paths";
import { ShellSession } from "./shell-session/shell-session";
injectSystemCAs(); injectSystemCAs();
@ -122,7 +123,7 @@ app.on("second-instance", (event, argv) => {
WindowManager.getInstance(false)?.ensureMainWindow(); WindowManager.getInstance(false)?.ensureMainWindow();
}); });
app.on("ready", async () => { app.on("ready", async () => {
logger.info(`🚀 Starting ${productName} from "${AppPaths.get("exe")}"`); logger.info(`🚀 Starting ${productName} from "${AppPaths.get("exe")}"`);
logger.info("🐚 Syncing shell environment"); logger.info("🐚 Syncing shell environment");
await shellSync(); await shellSync();
@ -134,6 +135,7 @@ app.on("ready", async () => {
registerFileProtocol("static", __static); registerFileProtocol("static", __static);
PrometheusProviderRegistry.createInstance(); PrometheusProviderRegistry.createInstance();
ShellRequestAuthenticator.createInstance().init();
initializers.initPrometheusProviderRegistry(); initializers.initPrometheusProviderRegistry();
/** /**
@ -227,6 +229,7 @@ app.on("ready", async () => {
onQuitCleanup.push( onQuitCleanup.push(
initMenu(windowManager), initMenu(windowManager),
initTray(windowManager), initTray(windowManager),
() => ShellSession.cleanup(),
); );
installDeveloperTools(); installDeveloperTools();

View File

@ -20,13 +20,12 @@
*/ */
import { BrowserWindow, dialog, IpcMainInvokeEvent } from "electron"; import { BrowserWindow, dialog, IpcMainInvokeEvent } from "electron";
import { KubernetesCluster } from "../../common/catalog-entities";
import { clusterFrameMap } from "../../common/cluster-frames"; import { clusterFrameMap } from "../../common/cluster-frames";
import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler, clusterSetDeletingHandler, clusterClearDeletingHandler } from "../../common/cluster-ipc"; import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler, clusterSetDeletingHandler, clusterClearDeletingHandler } from "../../common/cluster-ipc";
import type { ClusterId } from "../../common/cluster-types"; import type { ClusterId } from "../../common/cluster-types";
import { ClusterStore } from "../../common/cluster-store"; import { ClusterStore } from "../../common/cluster-store";
import { appEventBus } from "../../common/event-bus"; import { appEventBus } from "../../common/event-bus";
import { dialogShowOpenDialogHandler, ipcMainHandle } from "../../common/ipc"; import { dialogShowOpenDialogHandler, ipcMainHandle, ipcMainOn } from "../../common/ipc";
import { catalogEntityRegistry } from "../catalog"; import { catalogEntityRegistry } from "../catalog";
import { pushCatalogToRenderer } from "../catalog-pusher"; import { pushCatalogToRenderer } from "../catalog-pusher";
import { ClusterManager } from "../cluster-manager"; import { ClusterManager } from "../cluster-manager";
@ -54,16 +53,8 @@ export function initIpcMainHandlers() {
} }
}); });
ipcMainHandle(clusterVisibilityHandler, (event: IpcMainInvokeEvent, clusterId: ClusterId, visible: boolean) => { ipcMainOn(clusterVisibilityHandler, (event, clusterId?: ClusterId) => {
const entity = catalogEntityRegistry.getById(clusterId); ClusterManager.getInstance().visibleCluster = clusterId;
for (const kubeEntity of catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster)) {
kubeEntity.status.active = false;
}
if (entity) {
entity.status.active = visible;
}
}); });
ipcMainHandle(clusterRefreshHandler, (event, clusterId: ClusterId) => { ipcMainHandle(clusterRefreshHandler, (event, clusterId: ClusterId) => {

View File

@ -22,7 +22,6 @@
import { ChildProcess, spawn } from "child_process"; import { ChildProcess, spawn } from "child_process";
import { waitUntilUsed } from "tcp-port-used"; import { waitUntilUsed } from "tcp-port-used";
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { broadcastMessage } from "../common/ipc";
import type { Cluster } from "./cluster"; import type { Cluster } from "./cluster";
import { Kubectl } from "./kubectl"; import { Kubectl } from "./kubectl";
import logger from "./logger"; import logger from "./logger";
@ -30,22 +29,16 @@ import * as url from "url";
import { getPortFrom } from "./utils/get-port"; import { getPortFrom } from "./utils/get-port";
import { makeObservable, observable, when } from "mobx"; import { makeObservable, observable, when } from "mobx";
export interface KubeAuthProxyLog {
data: string;
error?: boolean; // stream=stderr
}
const startingServeRegex = /^starting to serve on (?<address>.+)/i; const startingServeRegex = /^starting to serve on (?<address>.+)/i;
export class KubeAuthProxy { export class KubeAuthProxy {
public lastError: string;
public readonly apiPrefix: string; public readonly apiPrefix: string;
public get port(): number { public get port(): number {
return this._port; return this._port;
} }
protected _port: number; protected _port?: number;
protected cluster: Cluster; protected cluster: Cluster;
protected env: NodeJS.ProcessEnv = null; protected env: NodeJS.ProcessEnv = null;
protected proxyProcess: ChildProcess; protected proxyProcess: ChildProcess;
@ -92,67 +85,56 @@ export class KubeAuthProxy {
this.proxyProcess = spawn(proxyBin, args, { env: this.env }); this.proxyProcess = spawn(proxyBin, args, { env: this.env });
this.proxyProcess.on("error", (error) => { this.proxyProcess.on("error", (error) => {
this.sendIpcLogMessage({ data: error.message, error: true }); this.cluster.broadcastConnectUpdate(error.message, true);
this.exit(); this.exit();
}); });
this.proxyProcess.on("exit", (code) => { this.proxyProcess.on("exit", (code) => {
this.sendIpcLogMessage({ data: `proxy exited with code: ${code}`, error: code > 0 }); this.cluster.broadcastConnectUpdate(`proxy exited with code: ${code}`, code > 0);
this.exit();
});
this.proxyProcess.on("disconnect", () => {
this.cluster.broadcastConnectUpdate("Proxy disconnected communications", true );
this.exit(); this.exit();
}); });
this.proxyProcess.stderr.on("data", (data) => { this.proxyProcess.stderr.on("data", (data) => {
this.lastError = this.parseError(data.toString()); this.cluster.broadcastConnectUpdate(data.toString(), true);
this.sendIpcLogMessage({ data: data.toString(), error: true }); });
this.proxyProcess.stdout.on("data", (data: any) => {
if (typeof this._port === "number") {
this.cluster.broadcastConnectUpdate(data.toString());
}
}); });
this._port = await getPortFrom(this.proxyProcess.stdout, { this._port = await getPortFrom(this.proxyProcess.stdout, {
lineRegex: startingServeRegex, lineRegex: startingServeRegex,
onFind: () => this.sendIpcLogMessage({ data: "Authentication proxy started\n" }), onFind: () => this.cluster.broadcastConnectUpdate("Authentication proxy started"),
}); });
this.proxyProcess.stdout.on("data", (data: any) => { try {
this.sendIpcLogMessage({ data: data.toString() }); await waitUntilUsed(this.port, 500, 10000);
}); this.ready = true;
} catch (error) {
this.cluster.broadcastConnectUpdate("Proxy port failed to be used within timelimit, restarting...", true);
this.exit();
await waitUntilUsed(this.port, 500, 10000); return this.run();
this.ready = true;
}
protected parseError(data: string) {
const error = data.split("http: proxy error:").slice(1).join("").trim();
let errorMsg = error;
const jsonError = error.split("Response: ")[1];
if (jsonError) {
try {
const parsedError = JSON.parse(jsonError);
errorMsg = parsedError.error_description || parsedError.error || jsonError;
} catch (_) {
errorMsg = jsonError.trim();
}
} }
return errorMsg;
}
protected sendIpcLogMessage(res: KubeAuthProxyLog) {
const channel = `kube-auth:${this.cluster.id}`;
logger.info(`[KUBE-AUTH]: out-channel "${channel}"`, { ...res, meta: this.cluster.getMeta() });
broadcastMessage(channel, res);
} }
public exit() { public exit() {
this.ready = false; this.ready = false;
if (!this.proxyProcess) return;
logger.debug("[KUBE-AUTH]: stopping local proxy", this.cluster.getMeta()); if (this.proxyProcess) {
this.proxyProcess.kill(); logger.debug("[KUBE-AUTH]: stopping local proxy", this.cluster.getMeta());
this.proxyProcess.removeAllListeners(); this.proxyProcess.removeAllListeners();
this.proxyProcess.stderr.removeAllListeners(); this.proxyProcess.stderr.removeAllListeners();
this.proxyProcess.stdout.removeAllListeners(); this.proxyProcess.stdout.removeAllListeners();
this.proxyProcess = null; this.proxyProcess.kill();
this.proxyProcess = null;
}
} }
} }

View File

@ -49,7 +49,7 @@ export class KubeconfigManager {
try { try {
this.tempFile = await this.createProxyKubeconfig(); this.tempFile = await this.createProxyKubeconfig();
} catch (err) { } catch (err) {
logger.error(`Failed to created temp config for auth-proxy`, { err }); logger.error(`[KUBECONFIG-MANAGER]: Failed to created temp config for auth-proxy`, { err });
} }
} }
@ -61,7 +61,7 @@ export class KubeconfigManager {
return; return;
} }
logger.info(`Deleting temporary kubeconfig: ${this.tempFile}`); logger.info(`[KUBECONFIG-MANAGER]: Deleting temporary kubeconfig: ${this.tempFile}`);
await fs.unlink(this.tempFile); await fs.unlink(this.tempFile);
} }
@ -70,7 +70,7 @@ export class KubeconfigManager {
return; return;
} }
logger.info(`Deleting temporary kubeconfig: ${this.tempFile}`); logger.info(`[KUBECONFIG-MANAGER]: Deleting temporary kubeconfig: ${this.tempFile}`);
await fs.unlink(this.tempFile); await fs.unlink(this.tempFile);
this.tempFile = undefined; this.tempFile = undefined;
} }
@ -80,8 +80,7 @@ export class KubeconfigManager {
await this.contextHandler.ensureServer(); await this.contextHandler.ensureServer();
this.tempFile = await this.createProxyKubeconfig(); this.tempFile = await this.createProxyKubeconfig();
} catch (err) { } catch (err) {
console.log(err); logger.error(`[KUBECONFIG-MANAGER]: Failed to created temp config for auth-proxy`, err);
logger.error(`Failed to created temp config for auth-proxy`, { err });
} }
} }
@ -124,7 +123,7 @@ export class KubeconfigManager {
await fs.ensureDir(path.dirname(tempFile)); await fs.ensureDir(path.dirname(tempFile));
await fs.writeFile(tempFile, configYaml, { mode: 0o600 }); await fs.writeFile(tempFile, configYaml, { mode: 0o600 });
logger.debug(`Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`); logger.debug(`[KUBECONFIG-MANAGER]: Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`);
return tempFile; return tempFile;
} }

View File

@ -60,12 +60,10 @@ export class LensProxy extends Singleton {
public port: number; public port: number;
constructor(protected router: Router, functions: LensProxyFunctions) { constructor(protected router: Router, { shellApiRequest, kubeApiRequest, getClusterForRequest }: LensProxyFunctions) {
super(); super();
const { shellApiRequest, kubeApiRequest } = functions; this.getClusterForRequest = getClusterForRequest;
this.getClusterForRequest = functions.getClusterForRequest;
this.proxyServer = spdy.createServer({ this.proxyServer = spdy.createServer({
spdy: { spdy: {
@ -79,10 +77,16 @@ export class LensProxy extends Singleton {
this.proxyServer this.proxyServer
.on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => { .on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => {
const isInternal = req.url.startsWith(`${apiPrefix}?`); const isInternal = req.url.startsWith(`${apiPrefix}?`);
const cluster = getClusterForRequest(req);
if (!cluster) {
return void logger.error(`[LENS-PROXY]: Could not find cluster for upgrade request from url=${req.url}`);
}
const reqHandler = isInternal ? shellApiRequest : kubeApiRequest; const reqHandler = isInternal ? shellApiRequest : kubeApiRequest;
(async () => reqHandler({ req, socket, head }))() (async () => reqHandler({ req, socket, head, cluster }))()
.catch(error => logger.error(logger.error(`[LENS-PROXY]: failed to handle proxy upgrade: ${error}`))); .catch(error => logger.error("[LENS-PROXY]: failed to handle proxy upgrade", error));
}); });
} }
@ -142,6 +146,10 @@ export class LensProxy extends Singleton {
res.flushHeaders(); res.flushHeaders();
} }
} }
proxyRes.on("aborted", () => { // happens when proxy target aborts connection
res.end();
});
}); });
proxy.on("error", (error, req, res, target) => { proxy.on("error", (error, req, res, target) => {

View File

@ -23,18 +23,11 @@ import { chunk } from "lodash";
import net from "net"; import net from "net";
import url from "url"; import url from "url";
import { apiKubePrefix } from "../../common/vars"; import { apiKubePrefix } from "../../common/vars";
import { ClusterManager } from "../cluster-manager";
import type { ProxyApiRequestArgs } from "./types"; import type { ProxyApiRequestArgs } from "./types";
const skipRawHeaders = new Set(["Host", "Authorization"]); const skipRawHeaders = new Set(["Host", "Authorization"]);
export async function kubeApiRequest({ req, socket, head }: ProxyApiRequestArgs) { export async function kubeApiRequest({ req, socket, head, cluster }: ProxyApiRequestArgs) {
const cluster = ClusterManager.getInstance().getClusterForRequest(req);
if (!cluster) {
return;
}
const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, ""); const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, "");
const apiUrl = url.parse(cluster.apiUrl); const apiUrl = url.parse(cluster.apiUrl);
const pUrl = url.parse(proxyUrl); const pUrl = url.parse(proxyUrl);

View File

@ -19,29 +19,81 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import type http from "http";
import url from "url";
import logger from "../logger"; import logger from "../logger";
import * as WebSocket from "ws"; import { Server as WebSocketServer } from "ws";
import { NodeShellSession, LocalShellSession } from "../shell-session"; import { NodeShellSession, LocalShellSession } from "../shell-session";
import type { ProxyApiRequestArgs } from "./types"; import type { ProxyApiRequestArgs } from "./types";
import { ClusterManager } from "../cluster-manager"; import { ClusterManager } from "../cluster-manager";
import URLParse from "url-parse";
import { ExtendedMap, Singleton } from "../../common/utils";
import type { ClusterId } from "../../common/cluster-types";
import { ipcMainHandle } from "../../common/ipc";
import crypto from "crypto";
import { promisify } from "util";
export function shellApiRequest({ req, socket, head }: ProxyApiRequestArgs) { const randomBytes = promisify(crypto.randomBytes);
const ws = new WebSocket.Server({ noServer: true });
ws.on("connection", ((socket: WebSocket, req: http.IncomingMessage) => { export class ShellRequestAuthenticator extends Singleton {
const cluster = ClusterManager.getInstance().getClusterForRequest(req); private tokens = new ExtendedMap<ClusterId, Map<string, Uint8Array>>();
const nodeParam = url.parse(req.url, true).query["node"]?.toString();
const shell = nodeParam init() {
? new NodeShellSession(socket, cluster, nodeParam) ipcMainHandle("cluster:shell-api", async (event, clusterId, tabId) => {
: new LocalShellSession(socket, cluster); const authToken = Uint8Array.from(await randomBytes(128));
this.tokens
.getOrInsert(clusterId, () => new Map())
.set(tabId, authToken);
return authToken;
});
}
/**
* Authenticates a single use token for creating a new shell
* @param clusterId The `ClusterId` for the shell
* @param tabId The ID for the shell
* @param token The value that is being presented as a one time authentication token
* @returns `true` if `token` was valid, false otherwise
*/
authenticate(clusterId: ClusterId, tabId: string, token: string): boolean {
const clusterTokens = this.tokens.get(clusterId);
if (!clusterTokens) {
return false;
}
const authToken = clusterTokens.get(tabId);
const buf = Uint8Array.from(Buffer.from(token, "base64"));
if (authToken instanceof Uint8Array && authToken.length === buf.length && crypto.timingSafeEqual(authToken, buf)) {
// remove the token because it is a single use token
clusterTokens.delete(tabId);
return true;
}
return false;
}
}
export function shellApiRequest({ req, socket, head }: ProxyApiRequestArgs): void {
const cluster = ClusterManager.getInstance().getClusterForRequest(req);
const { query: { node, shellToken, id: tabId }} = new URLParse(req.url, true);
if (!cluster || !ShellRequestAuthenticator.getInstance().authenticate(cluster.id, tabId, shellToken)) {
socket.write("Invalid shell request");
return void socket.end();
}
const ws = new WebSocketServer({ noServer: true });
ws.handleUpgrade(req, socket, head, (webSocket) => {
const shell = node
? new NodeShellSession(webSocket, cluster, node, tabId)
: new LocalShellSession(webSocket, cluster, tabId);
shell.open() shell.open()
.catch(error => logger.error(`[SHELL-SESSION]: failed to open: ${error}`, { error })); .catch(error => logger.error(`[SHELL-SESSION]: failed to open a ${node ? "node" : "local"} shell`, error));
}));
ws.handleUpgrade(req, socket, head, (con) => {
ws.emit("connection", con, req);
}); });
} }

View File

@ -21,9 +21,11 @@
import type http from "http"; import type http from "http";
import type net from "net"; import type net from "net";
import type { Cluster } from "../cluster";
export interface ProxyApiRequestArgs { export interface ProxyApiRequestArgs {
req: http.IncomingMessage, req: http.IncomingMessage,
socket: net.Socket, socket: net.Socket,
head: Buffer, head: Buffer,
cluster: Cluster,
} }

View File

@ -46,7 +46,7 @@ async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPa
return await getMetrics(cluster, prometheusPath, { query, ...queryParams }); return await getMetrics(cluster, prometheusPath, { query, ...queryParams });
} catch (error) { } catch (error) {
if (lastAttempt || (error?.statusCode >= 400 && error?.statusCode < 500)) { if (lastAttempt || (error?.statusCode >= 400 && error?.statusCode < 500)) {
logger.error("[Metrics]: metrics not available", { error }); logger.error("[Metrics]: metrics not available", error);
throw new Error("Metrics not available"); throw new Error("Metrics not available");
} }

View File

@ -40,7 +40,7 @@ export class LocalShellSession extends ShellSession {
const shell = env.PTYSHELL; const shell = env.PTYSHELL;
const args = await this.getShellArgs(shell); const args = await this.getShellArgs(shell);
super.open(env.PTYSHELL, args, env); await this.openShellProcess(env.PTYSHELL, args, env);
} }
protected async getShellArgs(shell: string): Promise<string[]> { protected async getShellArgs(shell: string): Promise<string[]> {

View File

@ -19,26 +19,27 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import type * as WebSocket from "ws"; import type WebSocket from "ws";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import * as k8s from "@kubernetes/client-node"; import * as k8s from "@kubernetes/client-node";
import type { KubeConfig } from "@kubernetes/client-node"; import type { KubeConfig } from "@kubernetes/client-node";
import type { Cluster } from "../cluster"; import type { Cluster } from "../cluster";
import { ShellOpenError, ShellSession } from "./shell-session"; import { ShellOpenError, ShellSession } from "./shell-session";
import { get } from "lodash"; import { get } from "lodash";
import { Node, NodesApi } from "../../common/k8s-api/endpoints";
import { KubeJsonApi } from "../../common/k8s-api/kube-json-api";
import logger from "../logger";
export class NodeShellSession extends ShellSession { export class NodeShellSession extends ShellSession {
ShellType = "node-shell"; ShellType = "node-shell";
protected podId = `node-shell-${uuid()}`; protected readonly podName = `node-shell-${uuid()}`;
protected kc: KubeConfig; protected kc: KubeConfig;
protected get cwd(): string | undefined { protected readonly cwd: string | undefined = undefined;
return undefined;
}
constructor(socket: WebSocket, cluster: Cluster, protected nodeName: string) { constructor(socket: WebSocket, cluster: Cluster, protected nodeName: string, terminalId: string) {
super(socket, cluster); super(socket, cluster, terminalId);
} }
public async open() { public async open() {
@ -55,10 +56,27 @@ export class NodeShellSession extends ShellSession {
throw new ShellOpenError("failed to create node pod", error); throw new ShellOpenError("failed to create node pod", error);
} }
const args = ["exec", "-i", "-t", "-n", "kube-system", this.podId, "--", "sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"];
const env = await this.getCachedShellEnv(); const env = await this.getCachedShellEnv();
const args = ["exec", "-i", "-t", "-n", "kube-system", this.podName, "--"];
const nodeApi = new NodesApi({
objectConstructor: Node,
request: KubeJsonApi.forCluster(this.cluster),
});
const node = await nodeApi.get({ name: this.nodeName });
const nodeOs = node.getOperatingSystem();
await super.open(shell, args, env); switch (nodeOs) {
default:
logger.warn(`[NODE-SHELL-SESSION]: could not determine node OS, falling back with assumption of linux`);
case "linux":
args.push("sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))");
break;
case "windows":
args.push("powershell");
break;
}
await this.openShellProcess(shell, args, env);
} }
protected createNodeShellPod() { protected createNodeShellPod() {
@ -73,7 +91,7 @@ export class NodeShellSession extends ShellSession {
.makeApiClient(k8s.CoreV1Api) .makeApiClient(k8s.CoreV1Api)
.createNamespacedPod("kube-system", { .createNamespacedPod("kube-system", {
metadata: { metadata: {
name: this.podId, name: this.podName,
namespace: "kube-system", namespace: "kube-system",
}, },
spec: { spec: {
@ -101,33 +119,39 @@ export class NodeShellSession extends ShellSession {
} }
protected waitForRunningPod(): Promise<void> { protected waitForRunningPod(): Promise<void> {
return new Promise((resolve, reject) => { logger.debug(`[NODE-SHELL]: waiting for ${this.podName} to be running`);
const watch = new k8s.Watch(this.kc);
watch return new Promise((resolve, reject) => {
new k8s.Watch(this.kc)
.watch(`/api/v1/namespaces/kube-system/pods`, .watch(`/api/v1/namespaces/kube-system/pods`,
{}, {},
// callback is called for each received object. // callback is called for each received object.
(type, obj) => { (type, { metadata: { name }, status }) => {
if (obj.metadata.name == this.podId && obj.status.phase === "Running") { if (name === this.podName) {
resolve(); switch (status.phase) {
case "Running":
return resolve();
case "Failed":
return reject(`Failed to be created: ${status.message || "unknown error"}`);
}
} }
}, },
// done callback is called if the watch terminates normally // done callback is called if the watch terminates normally
(err) => { (err) => {
console.log(err); logger.error(`[NODE-SHELL]: ${this.podName} was not created in time`);
reject(err); reject(err);
}, },
) )
.then(req => { .then(req => {
setTimeout(() => { setTimeout(() => {
console.log("aborting"); logger.error(`[NODE-SHELL]: aborting wait for ${this.podName}, timing out`);
req.abort(); req.abort();
}, 2 * 60 * 1000); reject("Pod creation timed out");
}, 2 * 60 * 1000); // 2 * 60 * 1000
}) })
.catch(err => { .catch(error => {
console.log("watch failed"); logger.error(`[NODE-SHELL]: waiting for ${this.podName} failed: ${error}`);
reject(err); reject(error);
}); });
}); });
} }
@ -141,6 +165,7 @@ export class NodeShellSession extends ShellSession {
this this
.kc .kc
.makeApiClient(k8s.CoreV1Api) .makeApiClient(k8s.CoreV1Api)
.deleteNamespacedPod(this.podId, "kube-system"); .deleteNamespacedPod(this.podName, "kube-system")
.catch(error => logger.warn(`[NODE-SHELL]: failed to remove pod shell`, error));
} }
} }

View File

@ -22,7 +22,7 @@
import fse from "fs-extra"; import fse from "fs-extra";
import type { Cluster } from "../cluster"; import type { Cluster } from "../cluster";
import { Kubectl } from "../kubectl"; import { Kubectl } from "../kubectl";
import type * as WebSocket from "ws"; import type WebSocket from "ws";
import { shellEnv } from "../utils/shell-env"; import { shellEnv } from "../utils/shell-env";
import { app } from "electron"; import { app } from "electron";
import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars"; import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars";
@ -31,6 +31,7 @@ import { isWindows } from "../../common/vars";
import { UserStore } from "../../common/user-store"; import { UserStore } from "../../common/user-store";
import * as pty from "node-pty"; import * as pty from "node-pty";
import { appEventBus } from "../../common/event-bus"; import { appEventBus } from "../../common/event-bus";
import logger from "../logger";
export class ShellOpenError extends Error { export class ShellOpenError extends Error {
constructor(message: string, public cause: Error) { constructor(message: string, public cause: Error) {
@ -40,41 +41,140 @@ export class ShellOpenError extends Error {
} }
} }
export enum WebSocketCloseEvent {
/**
* The connection successfully completed the purpose for which it was created.
*/
NormalClosure = 1000,
/**
* The endpoint is going away, either because of a server failure or because
* the browser is navigating away from the page that opened the connection.
*/
GoingAway = 1001,
/**
* The endpoint is terminating the connection due to a protocol error.
*/
ProtocolError = 1002,
/**
* The connection is being terminated because the endpoint received data of a
* type it cannot accept. (For example, a text-only endpoint received binary
* data.)
*/
UnsupportedData = 1003,
/**
* Indicates that no status code was provided even though one was expected.
*/
NoStatusReceived = 1005,
/**
* Indicates that a connection was closed abnormally (that is, with no close
* frame being sent) when a status code is expected.
*/
AbnormalClosure = 1006,
/**
* The endpoint is terminating the connection because a message was received
* that contained inconsistent data (e.g., non-UTF-8 data within a text message).
*/
InvalidFramePayloadData = 1007,
/**
* The endpoint is terminating the connection because it received a message
* that violates its policy. This is a generic status code, used when codes
* 1003 and 1009 are not suitable.
*/
PolicyViolation = 1008,
/**
* The endpoint is terminating the connection because a data frame was
* received that is too large.
*/
MessageTooBig = 1009,
/**
* The client is terminating the connection because it expected the server to
* negotiate one or more extension, but the server didn't.
*/
MissingExtension = 1010,
/**
* The server is terminating the connection because it encountered an
* unexpected condition that prevented it from fulfilling the request.
*/
InternalError = 1011,
/**
* The server is terminating the connection because it is restarting.
*/
ServiceRestart = 1012,
/**
* The server is terminating the connection due to a temporary condition,
* e.g. it is overloaded and is casting off some of its clients.
*/
TryAgainLater = 1013,
/**
* The server was acting as a gateway or proxy and received an invalid
* response from the upstream server. This is similar to 502 HTTP Status Code.
*/
BadGateway = 1014,
/**
* Indicates that the connection was closed due to a failure to perform a TLS
* handshake (e.g., the server certificate can't be verified).
*/
TlsHandshake = 1015,
}
export abstract class ShellSession { export abstract class ShellSession {
abstract ShellType: string; abstract ShellType: string;
static shellEnvs: Map<string, Record<string, any>> = new Map(); private static shellEnvs = new Map<string, Record<string, string>>();
private static processes = new Map<string, pty.IPty>();
/**
* Kill all remaining shell backing processes. Should be called when about to
* quit
*/
public static cleanup(): void {
for (const shellProcess of this.processes.values()) {
try {
process.kill(shellProcess.pid);
} catch {}
}
this.processes.clear();
}
protected kubectl: Kubectl; protected kubectl: Kubectl;
protected running = false; protected running = false;
protected shellProcess: pty.IPty;
protected kubectlBinDirP: Promise<string>; protected kubectlBinDirP: Promise<string>;
protected kubeconfigPathP: Promise<string>; protected kubeconfigPathP: Promise<string>;
protected readonly terminalId: string;
protected abstract get cwd(): string | undefined; protected abstract get cwd(): string | undefined;
constructor(protected websocket: WebSocket, protected cluster: Cluster) { protected ensureShellProcess(shell: string, args: string[], env: Record<string, string>, cwd: string): pty.IPty {
if (!ShellSession.processes.has(this.terminalId)) {
ShellSession.processes.set(this.terminalId, pty.spawn(shell, args, {
cols: 80,
cwd,
env,
name: "xterm-256color",
rows: 30,
}));
}
return ShellSession.processes.get(this.terminalId);
}
constructor(protected websocket: WebSocket, protected cluster: Cluster, terminalId: string) {
this.kubectl = new Kubectl(cluster.version); this.kubectl = new Kubectl(cluster.version);
this.kubeconfigPathP = this.cluster.getProxyKubeconfigPath(); this.kubeconfigPathP = this.cluster.getProxyKubeconfigPath();
this.kubectlBinDirP = this.kubectl.binDir(); this.kubectlBinDirP = this.kubectl.binDir();
this.terminalId = `${cluster.id}:${terminalId}`;
} }
protected async open(shell: string, args: string[], env: Record<string, any>) { protected async openShellProcess(shell: string, args: string[], env: Record<string, any>) {
const cwd = (this.cwd && await fse.pathExists(this.cwd)) const cwd = (this.cwd && await fse.pathExists(this.cwd))
? this.cwd ? this.cwd
: env.HOME; : env.HOME;
const shellProcess = this.ensureShellProcess(shell, args, env, cwd);
this.shellProcess = pty.spawn(shell, args, {
cols: 80,
cwd,
env,
name: "xterm-256color",
rows: 30,
});
this.running = true; this.running = true;
shellProcess.onData(data => this.sendResponse(data));
this.shellProcess.onData(data => this.sendResponse(data)); shellProcess.onExit(({ exitCode }) => {
this.shellProcess.onExit(({ exitCode }) => {
this.running = false; this.running = false;
if (exitCode > 0) { if (exitCode > 0) {
@ -95,24 +195,28 @@ export abstract class ShellSession {
switch (data[0]) { switch (data[0]) {
case "0": case "0":
this.shellProcess.write(message); shellProcess.write(message);
break; break;
case "4": case "4":
const { Width, Height } = JSON.parse(message); const { Width, Height } = JSON.parse(message);
this.shellProcess.resize(Width, Height); shellProcess.resize(Width, Height);
break; break;
} }
}) })
.on("close", () => { .on("close", (code) => {
if (this.running) { logger.debug(`[SHELL-SESSION]: websocket for ${this.terminalId} closed with code=${code}`);
if (this.running && code !== WebSocketCloseEvent.AbnormalClosure) {
// This code is the one that gets sent when the network is turned off
try { try {
process.kill(this.shellProcess.pid); logger.info(`[SHELL-SESSION]: Killing shell process for ${this.terminalId}`);
process.kill(shellProcess.pid);
ShellSession.processes.delete(this.terminalId);
} catch (e) { } catch (e) {
} }
this.running = false;
} }
this.running = false;
}); });
appEventBus.emit({ name: this.ShellType, action: "open" }); appEventBus.emit({ name: this.ShellType, action: "open" });
@ -191,7 +295,7 @@ export abstract class ShellSession {
return env; return env;
} }
protected exit(code = 1000) { protected exit(code = WebSocketCloseEvent.NormalClosure) {
if (this.websocket.readyState == this.websocket.OPEN) { if (this.websocket.readyState == this.websocket.OPEN) {
this.websocket.close(code); this.websocket.close(code);
} }

View File

@ -22,10 +22,8 @@
import { shellEnv } from "./utils/shell-env"; import { shellEnv } from "./utils/shell-env";
import os from "os"; import os from "os";
import { app } from "electron"; import { app } from "electron";
import logger from "./logger";
interface Env { import { isSnap } from "../common/vars";
[key: string]: string;
}
/** /**
* shellSync loads what would have been the environment if this application was * shellSync loads what would have been the environment if this application was
@ -33,12 +31,7 @@ interface Env {
* useful on macos where this always needs to be done. * useful on macos where this always needs to be done.
*/ */
export async function shellSync() { export async function shellSync() {
const { shell } = os.userInfo(); const env = await shellEnv(os.userInfo().shell);
let envVars = {};
envVars = await shellEnv(shell);
const env: Env = JSON.parse(JSON.stringify(envVars));
if (!env.LANG) { if (!env.LANG) {
// the LANG env var expects an underscore instead of electron's dash // the LANG env var expects an underscore instead of electron's dash
@ -47,9 +40,8 @@ export async function shellSync() {
env.LANG += ".UTF-8"; env.LANG += ".UTF-8";
} }
// Overwrite PATH on darwin if (!isSnap) {
if (process.env.NODE_ENV === "production" && process.platform === "darwin") { process.env.PATH = env.PATH;
process.env["PATH"] = env.PATH;
} }
// The spread operator allows joining of objects. The precedence is last to first. // The spread operator allows joining of objects. The precedence is last to first.
@ -57,4 +49,6 @@ export async function shellSync() {
...env, ...env,
...process.env, ...process.env,
}; };
logger.debug(`[SHELL-SYNC]: Synced shell env, and updating`, env, process.env);
} }

View File

@ -22,9 +22,7 @@
import shellEnvironment from "shell-env"; import shellEnvironment from "shell-env";
import logger from "../logger"; import logger from "../logger";
export interface EnvironmentVariables { export type EnvironmentVariables = Record<string, string>;
readonly [key: string]: string;
}
let shellSyncFailed = false; let shellSyncFailed = false;
@ -40,17 +38,15 @@ let shellSyncFailed = false;
* returned if the call fails. * returned if the call fails.
*/ */
export async function shellEnv(shell?: string, forceRetry = false) : Promise<EnvironmentVariables> { export async function shellEnv(shell?: string, forceRetry = false) : Promise<EnvironmentVariables> {
let envVars = {};
if (forceRetry) { if (forceRetry) {
shellSyncFailed = false; shellSyncFailed = false;
} }
if (!shellSyncFailed) { if (!shellSyncFailed) {
try { try {
envVars = await Promise.race([ return await Promise.race([
shellEnvironment(shell), shellEnvironment(shell),
new Promise((_resolve, reject) => setTimeout(() => { new Promise<EnvironmentVariables>((_resolve, reject) => setTimeout(() => {
reject(new Error("Resolving shell environment is taking very long. Please review your shell configuration.")); reject(new Error("Resolving shell environment is taking very long. Please review your shell configuration."));
}, 30_000)), }, 30_000)),
]); ]);
@ -62,5 +58,5 @@ export async function shellEnv(shell?: string, forceRetry = false) : Promise<Env
logger.error("shellSync(): Resolving shell environment took too long. Please review your shell configuration."); logger.error("shellSync(): Resolving shell environment took too long. Please review your shell configuration.");
} }
return envVars; return {};
} }

View File

@ -119,7 +119,7 @@ export class WindowManager extends Singleton {
logger.info("[WINDOW-MANAGER]: Main window loaded"); logger.info("[WINDOW-MANAGER]: Main window loaded");
}) })
.on("will-attach-webview", (event, webPreferences, params) => { .on("will-attach-webview", (event, webPreferences, params) => {
logger.info("[WINDOW-MANAGER]: Attaching webview"); logger.debug("[WINDOW-MANAGER]: Attaching webview");
// Following is security recommendations because we allow webview tag (webviewTag: true) // Following is security recommendations because we allow webview tag (webviewTag: true)
// suggested by https://www.electronjs.org/docs/tutorial/security#11-verify-webview-options-before-creation // suggested by https://www.electronjs.org/docs/tutorial/security#11-verify-webview-options-before-creation
// and https://www.electronjs.org/docs/tutorial/security#10-do-not-use-allowpopups // and https://www.electronjs.org/docs/tutorial/security#10-do-not-use-allowpopups

View File

@ -19,13 +19,13 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { boundMethod, base64, EventEmitter } from "../utils"; import { boundMethod, base64, EventEmitter, getHostedClusterId } from "../utils";
import { WebSocketApi } from "./websocket-api"; import { WebSocketApi } from "./websocket-api";
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
import { isDevelopment } from "../../common/vars"; import { isDevelopment } from "../../common/vars";
import url from "url"; import url from "url";
import { makeObservable, observable } from "mobx"; import { makeObservable, observable } from "mobx";
import type { ParsedUrlQueryInput } from "querystring"; import { ipcRenderer } from "electron";
export enum TerminalChannels { export enum TerminalChannels {
STDIN = 0, STDIN = 0,
@ -50,7 +50,7 @@ enum TerminalColor {
export type TerminalApiQuery = Record<string, string> & { export type TerminalApiQuery = Record<string, string> & {
id: string; id: string;
node?: string; node?: string;
type?: string | "node"; type?: string;
}; };
export class TerminalApi extends WebSocketApi { export class TerminalApi extends WebSocketApi {
@ -58,9 +58,8 @@ export class TerminalApi extends WebSocketApi {
public onReady = new EventEmitter<[]>(); public onReady = new EventEmitter<[]>();
@observable public isReady = false; @observable public isReady = false;
public readonly url: string;
constructor(protected options: TerminalApiQuery) { constructor(protected query: TerminalApiQuery) {
super({ super({
logging: isDevelopment, logging: isDevelopment,
flushOnOpen: false, flushOnOpen: false,
@ -68,30 +67,41 @@ export class TerminalApi extends WebSocketApi {
}); });
makeObservable(this); makeObservable(this);
const { hostname, protocol, port } = location; if (query.node) {
const query: ParsedUrlQueryInput = { query.type ||= "node";
id: options.id, }
}; }
if (options.node) { async connect() {
query.node = options.node; if (!this.socket) {
query.type = options.type || "node"; /**
* Only emit this message if we are not "reconnecting", so as to keep the
* output display clean when the computer wakes from sleep
*/
this.emitStatus("Connecting ...");
} }
this.url = url.format({ const authTokenArray = await ipcRenderer.invoke("cluster:shell-api", getHostedClusterId(), this.query.id);
if (!(authTokenArray instanceof Uint8Array)) {
throw new TypeError("ShellApi token is not a Uint8Array");
}
const { hostname, protocol, port } = location;
const socketUrl = url.format({
protocol: protocol.includes("https") ? "wss" : "ws", protocol: protocol.includes("https") ? "wss" : "ws",
hostname, hostname,
port, port,
pathname: "/api", pathname: "/api",
query, query: {
...this.query,
shellToken: Buffer.from(authTokenArray).toString("base64"),
},
slashes: true, slashes: true,
}); });
}
connect() {
this.emitStatus("Connecting ...");
this.onData.addListener(this._onReady, { prepend: true }); this.onData.addListener(this._onReady, { prepend: true });
super.connect(this.url); super.connect(socketUrl);
} }
destroy() { destroy() {

View File

@ -37,11 +37,11 @@ interface IMessage {
} }
export enum WebSocketApiState { export enum WebSocketApiState {
PENDING = -1, PENDING = "pending",
OPEN, OPEN = "open",
CONNECTING, CONNECTING = "connecting",
RECONNECTING, RECONNECTING = "reconnecting",
CLOSED, CLOSED = "closed",
} }
export class WebSocketApi { export class WebSocketApi {

View File

@ -22,7 +22,7 @@
.HelmChartDetails { .HelmChartDetails {
.intro-logo { .intro-logo {
margin-right: $margin * 2; margin-right: $margin * 2;
background: $helmLogoBackground; background: var(--helmLogoBackground);
border-radius: $radius; border-radius: $radius;
max-width: 150px; max-width: 150px;
max-height: 100px; max-height: 100px;
@ -39,7 +39,7 @@
.intro-contents { .intro-contents {
.description { .description {
font-weight: bold; font-weight: bold;
color: $textColorAccent; color: var(--textColorAccent);
padding-bottom: $padding; padding-bottom: $padding;
.Button { .Button {

View File

@ -49,9 +49,9 @@ const LargeTooltip = withStyles({
@observer @observer
export class HelmChartDetails extends Component<Props> { export class HelmChartDetails extends Component<Props> {
@observable chartVersions: HelmChart[]; @observable chartVersions: HelmChart[];
@observable selectedChart: HelmChart; @observable selectedChart?: HelmChart;
@observable readme: string = null; @observable readme?: string;
@observable error: string = null; @observable error?: string;
private abortController?: AbortController; private abortController?: AbortController;
@ -68,6 +68,10 @@ export class HelmChartDetails extends Component<Props> {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
reaction(() => this.props.chart, async ({ name, repo, version }) => { reaction(() => this.props.chart, async ({ name, repo, version }) => {
try { try {
this.selectedChart = undefined;
this.chartVersions = undefined;
this.readme = undefined;
const { readme, versions } = await getChartDetails(repo, name, { version }); const { readme, versions } = await getChartDetails(repo, name, { version });
this.readme = readme; this.readme = readme;

View File

@ -48,7 +48,7 @@
width: $iconSize; width: $iconSize;
height: $iconSize; height: $iconSize;
border-radius: 50%; border-radius: 50%;
background: $helmImgBackground url(helm-placeholder.svg) center center no-repeat; background: var(--helmImgBackground) url(helm-placeholder.svg) center center no-repeat;
background-size: 72%; // bg size looks same as image on top of it background-size: 72%; // bg size looks same as image on top of it
margin: auto; margin: auto;
@ -58,7 +58,7 @@
height: inherit; height: inherit;
visibility: hidden; visibility: hidden;
border-radius: inherit; border-radius: inherit;
background-color: $helmImgBackground; background-color: var(--helmImgBackground);
padding: $padding * 0.5; padding: $padding * 0.5;
&.visible { &.visible {
@ -74,11 +74,11 @@
&.repository { &.repository {
&.stable { &.stable {
color: $helmStableRepo; color: var(--helmStableRepo);
} }
&.incubator { &.incubator {
color: $helmIncubatorRepo; color: var(--helmIncubatorRepo);
} }
} }
} }

View File

@ -20,12 +20,12 @@
*/ */
$release-status-color-list: ( $release-status-color-list: (
deployed: $colorSuccess, deployed: var(--colorSuccess),
failed: $colorError, failed: var(--colorError),
deleting: $colorWarning, deleting: var(--colorWarning),
pendingInstall: $colorInfo, pendingInstall: var(--colorInfo),
pendingUpgrade: $colorInfo, pendingUpgrade: var(--colorInfo),
pendingRollback: $colorInfo, pendingRollback: var(--colorInfo),
); );
@mixin release-status-bgs { @mixin release-status-bgs {

View File

@ -32,7 +32,7 @@
margin-left: $margin; margin-left: $margin;
&.new-version { &.new-version {
color: $colorInfo; color: var(--colorInfo);
} }
} }
} }

View File

@ -26,12 +26,9 @@ import type { ItemObject } from "../../../common/item.store";
import { Badge } from "../badge"; import { Badge } from "../badge";
import { navigation } from "../../navigation"; import { navigation } from "../../navigation";
import { searchUrlParam } from "../input"; import { searchUrlParam } from "../input";
import { makeCss } from "../../../common/utils/makeCss";
import { KubeObject } from "../../../common/k8s-api/kube-object"; import { KubeObject } from "../../../common/k8s-api/kube-object";
import type { CatalogEntityRegistry } from "../../api/catalog-entity-registry"; import type { CatalogEntityRegistry } from "../../api/catalog-entity-registry";
const css = makeCss(styles);
export class CatalogEntityItem<T extends CatalogEntity> implements ItemObject { export class CatalogEntityItem<T extends CatalogEntity> implements ItemObject {
constructor(public entity: T, private registry: CatalogEntityRegistry) { constructor(public entity: T, private registry: CatalogEntityRegistry) {
if (!(entity instanceof CatalogEntity)) { if (!(entity instanceof CatalogEntity)) {
@ -79,7 +76,7 @@ export class CatalogEntityItem<T extends CatalogEntity> implements ItemObject {
return this.labels return this.labels
.map(label => ( .map(label => (
<Badge <Badge
className={css.badge} className={styles.badge}
key={label} key={label}
label={label} label={label}
title={label} title={label}

View File

@ -19,59 +19,77 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
.list :global(.TableRow) { .Catalog {
.entityName { :global(.TableHead) .entityName {
position: relative; /* offset for icon to align texts in the column */
width: min-content; padding-left: calc(21px + var(--padding) * 2.5);
max-width: 100%; }
:global(.TableRow):hover .pinIcon {
display: block;
}
}
.entityName {
position: relative;
width: 100%;
max-width: 100%;
display: flex;
align-items: center;
padding: 0 var(--padding);
padding-bottom: 0;
padding-right: 24px; // + reserved space for .pinIcon
> span {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
padding-right: 24px; padding-left: var(--padding);
}
.pinIcon { :global(.HotbarIcon){
position: absolute; align-self: center;
right: 0;
opacity: 0;
transition: none;
&:hover { div {
/* Drop styles defined for <Icon/> */ /* icons with plain text */
background-color: transparent; font-size: var(--unit);
box-shadow: none; }
}
.Icon {
/* icons with font-icon */
font-size: var(--small-size);
} }
} }
&:hover .pinIcon { .pinIcon {
opacity: 1; position: absolute;
right: 0;
transition: none;
display: none;
&:hover {
/* Drop styles defined for <Icon/> */
background-color: transparent;
box-shadow: none;
}
} }
} }
.iconCell {
@apply flex items-center max-w-[40px];
}
.iconCell > div * {
font-size: var(--unit);
}
.nameCell {
}
.sourceCell { .sourceCell {
max-width: 100px; max-width: 100px;
} }
.statusCell { .statusCell {
max-width: 100px; max-width: 100px;
}
.connected, .available { :global {
color: var(--colorSuccess); .connected, .available {
} color: var(--colorSuccess);
}
.disconnected, .deleting, .unavailable { .disconnected, .deleting, .unavailable {
color: var(--halfGray); color: var(--halfGray);
}
}
} }
.labelsCell { .labelsCell {

View File

@ -37,8 +37,7 @@ import { CatalogAddButton } from "./catalog-add-button";
import type { RouteComponentProps } from "react-router"; import type { RouteComponentProps } from "react-router";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import { MainLayout } from "../layout/main-layout"; import { MainLayout } from "../layout/main-layout";
import { createStorage, cssNames, prevDefault } from "../../utils"; import { createStorage, prevDefault } from "../../utils";
import { makeCss } from "../../../common/utils/makeCss";
import { CatalogEntityDetails } from "./catalog-entity-details"; import { CatalogEntityDetails } from "./catalog-entity-details";
import { browseCatalogTab, catalogURL, CatalogViewRouteParam } from "../../../common/routes"; import { browseCatalogTab, catalogURL, CatalogViewRouteParam } from "../../../common/routes";
import { CatalogMenu } from "./catalog-menu"; import { CatalogMenu } from "./catalog-menu";
@ -56,8 +55,6 @@ enum sortBy {
status = "status", status = "status",
} }
const css = makeCss(styles);
interface Props extends RouteComponentProps<CatalogViewRouteParam> { interface Props extends RouteComponentProps<CatalogViewRouteParam> {
catalogEntityStore?: CatalogEntityStore; catalogEntityStore?: CatalogEntityStore;
} }
@ -73,6 +70,7 @@ export class Catalog extends React.Component<Props> {
makeObservable(this); makeObservable(this);
this.catalogEntityStore = props.catalogEntityStore; this.catalogEntityStore = props.catalogEntityStore;
} }
static defaultProps = { static defaultProps = {
catalogEntityStore: new CatalogEntityStore(), catalogEntityStore: new CatalogEntityStore(),
}; };
@ -105,7 +103,7 @@ export class Catalog extends React.Component<Props> {
this.activeTab = routeTab; this.activeTab = routeTab;
this.catalogEntityStore.activeCategory = item; this.catalogEntityStore.activeCategory = item;
}); });
} catch(error) { } catch (error) {
console.error(error); console.error(error);
Notifications.error(<p>Unknown category: {routeTab}</p>); Notifications.error(<p>Unknown category: {routeTab}</p>);
} }
@ -177,7 +175,7 @@ export class Catalog extends React.Component<Props> {
renderNavigation() { renderNavigation() {
return ( return (
<CatalogMenu activeItem={this.activeTab} onItemClick={this.onTabChange}/> <CatalogMenu activeItem={this.activeTab} onItemClick={this.onTabChange} />
); );
} }
@ -214,23 +212,7 @@ export class Catalog extends React.Component<Props> {
const isItemInHotbar = HotbarStore.getInstance().isAddedToActive(item.entity); const isItemInHotbar = HotbarStore.getInstance().isAddedToActive(item.entity);
return ( return (
<div className={styles.entityName}> <>
{item.name}
<Icon
small
className={styles.pinIcon}
material={!isItemInHotbar && "push_pin"}
svg={isItemInHotbar && "push_off"}
tooltip={isItemInHotbar ? "Remove from Hotbar" : "Add to Hotbar"}
onClick={prevDefault(() => isItemInHotbar ? this.removeFromHotbar(item) : this.addToHotbar(item))}
/>
</div>
);
}
renderIcon(item: CatalogEntityItem<CatalogEntity>) {
return (
<RenderDelay>
<HotbarIcon <HotbarIcon
uid={`catalog-icon-${item.getId()}`} uid={`catalog-icon-${item.getId()}`}
title={item.getName()} title={item.getName()}
@ -240,7 +222,16 @@ export class Catalog extends React.Component<Props> {
background={item.entity.spec.icon?.background} background={item.entity.spec.icon?.background}
size={24} size={24}
/> />
</RenderDelay> <span>{item.name}</span>
<Icon
small
className={styles.pinIcon}
material={!isItemInHotbar && "push_pin"}
svg={isItemInHotbar ? "push_off" : "push_pin"}
tooltip={isItemInHotbar ? "Remove from Hotbar" : "Add to Hotbar"}
onClick={prevDefault(() => isItemInHotbar ? this.removeFromHotbar(item) : this.addToHotbar(item))}
/>
</>
); );
} }
@ -254,11 +245,11 @@ export class Catalog extends React.Component<Props> {
return ( return (
<ItemListLayout <ItemListLayout
className={styles.Catalog}
tableId={tableId} tableId={tableId}
renderHeaderTitle={activeCategory?.metadata.name || "Browse All"} renderHeaderTitle={activeCategory?.metadata.name || "Browse All"}
isSelectable={false} isSelectable={false}
isConfigurable={true} isConfigurable={true}
className={styles.list}
store={this.catalogEntityStore} store={this.catalogEntityStore}
sortingCallbacks={{ sortingCallbacks={{
[sortBy.name]: item => item.name, [sortBy.name]: item => item.name,
@ -270,23 +261,21 @@ export class Catalog extends React.Component<Props> {
entity => entity.searchFields, entity => entity.searchFields,
]} ]}
renderTableHeader={[ renderTableHeader={[
{ title: "", className: css.iconCell, id: "icon" }, { title: "Name", className: styles.entityName, sortBy: sortBy.name, id: "name" },
{ title: "Name", className: css.nameCell, sortBy: sortBy.name, id: "name" }, !activeCategory && { title: "Kind", sortBy: sortBy.kind, id: "kind" },
!activeCategory && { title: "Kind", className: css.kindCell, sortBy: sortBy.kind, id: "kind" }, { title: "Source", className: styles.sourceCell, sortBy: sortBy.source, id: "source" },
{ title: "Source", className: css.sourceCell, sortBy: sortBy.source, id: "source" }, { title: "Labels", className: styles.labelsCell, id: "labels" },
{ title: "Labels", className: css.labelsCell, id: "labels" }, { title: "Status", className: styles.statusCell, sortBy: sortBy.status, id: "status" },
{ title: "Status", className: css.statusCell, sortBy: sortBy.status, id: "status" },
].filter(Boolean)} ].filter(Boolean)}
customizeTableRowProps={item => ({ customizeTableRowProps={item => ({
disabled: !item.enabled, disabled: !item.enabled,
})} })}
renderTableContents={item => [ renderTableContents={item => [
this.renderIcon(item),
this.renderName(item), this.renderName(item),
!activeCategory && item.kind, !activeCategory && item.kind,
item.source, item.source,
item.getLabelBadges(), item.getLabelBadges(),
{ title: item.phase, className: cssNames(css[item.phase]) }, <span key="phase" className={item.phase}>{item.phase}</span>,
].filter(Boolean)} ].filter(Boolean)}
onDetails={this.onDetails} onDetails={this.onDetails}
renderItemMenu={this.renderItemMenu} renderItemMenu={this.renderItemMenu}
@ -302,7 +291,7 @@ export class Catalog extends React.Component<Props> {
return ( return (
<MainLayout sidebar={this.renderNavigation()}> <MainLayout sidebar={this.renderNavigation()}>
<div className="p-6 h-full"> <div className="p-6 h-full">
{ this.renderList() } {this.renderList()}
</div> </div>
{ {
this.catalogEntityStore.selectedItem this.catalogEntityStore.selectedItem

View File

@ -20,9 +20,9 @@
*/ */
$hpa-status-colors: ( $hpa-status-colors: (
abletoscale: $colorOk, abletoscale: var(--colorOk),
scalingactive: $colorInfo, scalingactive: var(--colorInfo),
scalinglimited: $colorSoftError, scalinglimited: var(--colorSoftError),
); );
@mixin hpa-status-bgc { @mixin hpa-status-bgc {

View File

@ -50,11 +50,14 @@ interface Props extends RouteComponentProps<HpaRouteParams> {
export class HorizontalPodAutoscalers extends React.Component<Props> { export class HorizontalPodAutoscalers extends React.Component<Props> {
getTargets(hpa: HorizontalPodAutoscaler) { getTargets(hpa: HorizontalPodAutoscaler) {
const metrics = hpa.getMetrics(); const metrics = hpa.getMetrics();
const metricsRemainCount = metrics.length - 1;
const metricsRemain = metrics.length > 1 ? <>{metricsRemainCount} more...</> : null;
const metricValues = hpa.getMetricValues(metrics[0]);
return <p>{metricValues} {metricsRemain && "+"}{metricsRemain}</p>; if (metrics.length === 0) {
return <p>--</p>;
}
const metricsRemain = metrics.length > 1 ? `+${metrics.length - 1} more...` : "";
return <p>{hpa.getMetricValues(metrics[0])} {metricsRemain}</p>;
} }
render() { render() {

View File

@ -24,7 +24,7 @@
margin-bottom: $margin * 2; margin-bottom: $margin * 2;
.name { .name {
color: $textColorSecondary; color: var(--textColorSecondary);
font-weight: $font-weight-bold; font-weight: $font-weight-bold;
padding-bottom: $padding * 0.5; padding-bottom: $padding * 0.5;
} }

View File

@ -33,14 +33,14 @@
.quota { .quota {
--flex-gap: #{$padding}; --flex-gap: #{$padding};
border: 1px solid $halfGray; border: 1px solid var(--halfGray);
border-radius: $radius; border-radius: $radius;
margin: $margin * 0.5; margin: $margin * 0.5;
padding: $padding * 0.5 $padding; padding: $padding * 0.5 $padding;
transition: all 150ms ease; transition: all 150ms ease;
&:hover { &:hover {
box-shadow: inset 0 0 0 1px $borderColor; box-shadow: inset 0 0 0 1px var(--borderColor);
} }
.name { .name {
@ -48,7 +48,7 @@
} }
.value { .value {
color: $contentColor; color: var(--contentColor);
} }
.Icon:hover { .Icon:hover {

View File

@ -30,7 +30,7 @@
.LineProgress { .LineProgress {
margin-top: 3px; margin-top: 3px;
width: 100%; width: 100%;
color: $colorInfo; color: var(--colorInfo);
} }
} }
} }

View File

@ -24,7 +24,7 @@
margin-bottom: $margin * 2; margin-bottom: $margin * 2;
.name { .name {
color: $textColorSecondary; color: var(--textColorSecondary);
font-weight: $font-weight-bold; font-weight: $font-weight-bold;
padding-bottom: $padding * 0.5; padding-bottom: $padding * 0.5;
} }

View File

@ -92,6 +92,8 @@ export class CrdList extends React.Component {
tableId="crd" tableId="crd"
className="CrdList" className="CrdList"
store={crdStore} store={crdStore}
// Don't subscribe the `crdStore` because <Sidebar> already has and is always mounted
subscribeStores={false}
items={items} items={items}
sortingCallbacks={sortingCallbacks} sortingCallbacks={sortingCallbacks}
searchFilters={Object.values(sortingCallbacks)} searchFilters={Object.values(sortingCallbacks)}

View File

@ -23,7 +23,7 @@
.status { .status {
.ready { .ready {
color: white; color: white;
background-color: $colorOk; background-color: var(--colorOk);
} }
} }
} }

View File

@ -78,6 +78,17 @@ export class CrdResources extends React.Component<Props> {
sortingCallbacks[column.name] = item => jsonPath.value(item, parseJsonPath(column.jsonPath.slice(1))); sortingCallbacks[column.name] = item => jsonPath.value(item, parseJsonPath(column.jsonPath.slice(1)));
}); });
const version = crd.getPreferedVersion();
const loadFailedPrefix = <p>Failed to load {crd.getPluralName()}</p>;
const failedToLoadMessage = version.served
? loadFailedPrefix
: (
<>
{loadFailedPrefix}
<p>Prefered version ({crd.getGroup()}/{version.name}) is not served</p>
</>
);
return ( return (
<KubeObjectListLayout <KubeObjectListLayout
isConfigurable isConfigurable
@ -129,6 +140,7 @@ export class CrdResources extends React.Component<Props> {
}), }),
crdInstance.getAge(), crdInstance.getAge(),
]} ]}
failedToLoadMessage={failedToLoadMessage}
/> />
); );
} }

View File

@ -21,11 +21,11 @@
// CRD conditions from here https://github.com/kubernetes/apiextensions-apiserver/blob/master/pkg/apis/apiextensions/types.go // CRD conditions from here https://github.com/kubernetes/apiextensions-apiserver/blob/master/pkg/apis/apiextensions/types.go
$crd-condition-colors: ( $crd-condition-colors: (
Established: $colorSuccess, Established: var(--colorSuccess),
NamesAccepted: $colorOk, NamesAccepted: var(--colorOk),
NonStructuralSchema: $colorError, NonStructuralSchema: var(--colorError),
Terminating: $colorTerminated, Terminating: var(--colorTerminated),
KubernetesAPIApprovalPolicyConformant: $colorWarning KubernetesAPIApprovalPolicyConformant: var(--colorWarning)
); );
@mixin crd-condition-bgc { @mixin crd-condition-bgc {

View File

@ -22,7 +22,7 @@
.EventDetails { .EventDetails {
.type { .type {
.warning { .warning {
color: $colorError; color: var(--colorError);
} }
} }
} }

View File

@ -26,7 +26,7 @@
flex-grow: 3; flex-grow: 3;
&.warning { &.warning {
color: $colorError; color: var(--colorError);
} }
} }

View File

@ -34,7 +34,7 @@
word-break: break-word; word-break: break-word;
&.warning { &.warning {
color: $colorError; color: var(--colorError);
} }
} }
} }

View File

@ -21,7 +21,7 @@
.KubeEventIcon { .KubeEventIcon {
&.warning { &.warning {
color: $golden; color: var(--golden);
} }
} }

View File

@ -22,9 +22,9 @@
@mixin namespaceStatus { @mixin namespaceStatus {
&.active { &.active {
color: $colorOk; color: var(--colorOk);
} }
&.terminating { &.terminating {
color: $colorError; color: var(--colorError);
} }
} }

View File

@ -20,7 +20,7 @@
*/ */
.IngressDetails { .IngressDetails {
--titles-color: #{$textColorSecondary}; --titles-color: var(--textColorSecondary);
.rules { .rules {
margin-bottom: $margin * 2 margin-bottom: $margin * 2

View File

@ -31,7 +31,7 @@
span { span {
cursor: pointer; cursor: pointer;
color: $primary; color: var(--primary);
text-decoration: underline; text-decoration: underline;
padding-right: 1em; padding-right: 1em;
} }

View File

@ -164,7 +164,7 @@ export class ServicePortComponent extends React.Component<Props> {
<span title="Open in a browser" onClick={() => this.portForward()}> <span title="Open in a browser" onClick={() => this.portForward()}>
{port.toString()} {port.toString()}
</span> </span>
<Button onClick={() => portForwardAction()}> {this.isPortForwarded ? "Stop" : "Forward..."} </Button> <Button primary onClick={() => portForwardAction()}> {this.isPortForwarded ? "Stop" : "Forward..."} </Button>
{this.waiting && ( {this.waiting && (
<Spinner /> <Spinner />
)} )}

View File

@ -20,8 +20,8 @@
*/ */
$service-status-color-list: ( $service-status-color-list: (
active: $colorOk, active: var(--colorOk),
pending: $colorWarning pending: var(--colorWarning)
); );
@mixin service-status-colors { @mixin service-status-colors {
@ -33,7 +33,7 @@ $service-status-color-list: (
} }
$port-forward-status-color-list: ( $port-forward-status-color-list: (
active: $colorOk, active: var(--colorOk),
); );
@mixin port-forward-status-colors { @mixin port-forward-status-colors {

View File

@ -20,8 +20,8 @@
*/ */
.NodeDetails { .NodeDetails {
--status-ready-bg: #{$colorOk}; --status-ready-bg: var(--colorOk);
--status-default-bg: #{$colorError}; --status-default-bg: var(--colorError);
.conditions { .conditions {
@include node-status-bgs; @include node-status-bgs;

View File

@ -28,7 +28,7 @@ $node-status-color-list: (
ready: #4caf50, ready: #4caf50,
scheduling-disabled: #ff9800, scheduling-disabled: #ff9800,
invalid-license: #ce3933, invalid-license: #ce3933,
cordoned: $colorWarning cordoned: var(--colorWarning)
); );
@mixin node-status-bgs { @mixin node-status-bgs {

View File

@ -37,7 +37,7 @@
align-self: center; align-self: center;
.LineProgress { .LineProgress {
color: $lensBlue; color: var(--blue);
} }
} }
@ -46,7 +46,7 @@
align-self: center; align-self: center;
.LineProgress { .LineProgress {
color: $lensMagenta; color: var(--magenta);
} }
} }
@ -55,7 +55,7 @@
align-self: center; align-self: center;
.LineProgress { .LineProgress {
color: $golden; color: var(--golden);
} }
} }

View File

@ -20,15 +20,15 @@
*/ */
// PersistentVolumes // PersistentVolumes
$pv-bound: $colorOk; $pv-bound: var(--colorOk);
$pv-available: $colorSuccess; $pv-available: var(--colorSuccess);
$pv-released: $colorWarning; $pv-released: var(--colorWarning);
$pv-failed: $colorError; $pv-failed: var(--colorError);
// PersistentVolumeClaims // PersistentVolumeClaims
$pvc-bound: $colorOk; $pvc-bound: var(--colorOk);
$pvc-pending: $colorWarning; $pvc-pending: var(--colorWarning);
$pvc-lost: $colorError; $pvc-lost: var(--colorError);
// PersistentVolume Statuses // PersistentVolume Statuses
$pv-status-color-list: ( $pv-status-color-list: (

View File

@ -25,12 +25,12 @@
grid-template-columns: min-content auto; grid-template-columns: min-content auto;
gap: $margin; gap: $margin;
border: 1px solid $borderColor; border: 1px solid var(--borderColor);
border-radius: $radius; border-radius: $radius;
padding: $padding * 1.5; padding: $padding * 1.5;
> .name { > .name {
color: $textColorSecondary; color: var(--textColorSecondary);
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
} }

View File

@ -4,12 +4,12 @@
grid-template-columns: min-content auto; grid-template-columns: min-content auto;
gap: $margin; gap: $margin;
border: 1px solid $borderColor; border: 1px solid var(--borderColor);
border-radius: $radius; border-radius: $radius;
padding: $padding * 1.5; padding: $padding * 1.5;
> .name { > .name {
color: $textColorSecondary; color: var(--textColorSecondary);
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
} }

View File

@ -30,7 +30,7 @@
.secret-row { .secret-row {
display: flex; display: flex;
border-bottom: 1px solid $borderFaintColor; border-bottom: 1px solid var(--borderFaintColor);
padding: $padding 0; padding: $padding 0;
&:first-child { &:first-child {
@ -40,12 +40,12 @@
.name { .name {
flex-basis: 23%; flex-basis: 23%;
color: $drawerItemNameColor; color: var(--drawerItemNameColor);
} }
.value { .value {
flex-basis: 76%; flex-basis: 76%;
color: $drawerItemValueColor; color: var(--drawerItemValueColor);
word-break: break-all; word-break: break-all;
&:empty:after { &:empty:after {

View File

@ -27,7 +27,7 @@
margin-bottom: $margin; margin-bottom: $margin;
a { a {
color: $colorInfo; color: var(--colorInfo);
} }
} }
} }

View File

@ -56,7 +56,7 @@
} }
.warning { .warning {
color: $colorSoftError; color: var(--colorSoftError);
font-size: small; font-size: small;
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -23,14 +23,14 @@
position: relative; position: relative;
width: 100%; width: 100%;
min-width: $unit * 75; min-width: $unit * 75;
background: $contentColor; background: var(--contentColor);
> .header { > .header {
position: relative; position: relative;
padding: $padding * 2; padding: $padding * 2;
h5 { h5 {
color: $textColorPrimary; color: var(--textColorPrimary);
} }
} }
@ -49,7 +49,7 @@
text-align: center; text-align: center;
a { a {
color: $colorInfo; color: var(--colorInfo);
} }
} }
} }

View File

@ -35,7 +35,7 @@
} }
.var-name { .var-name {
color: $textColorPrimary color: var(--textColorPrimary)
} }
} }
} }

View File

@ -31,7 +31,7 @@
span { span {
cursor: pointer; cursor: pointer;
color: $primary; color: var(--primary);
text-decoration: underline; text-decoration: underline;
position: relative; position: relative;
padding-right: 1em; padding-right: 1em;

View File

@ -170,7 +170,7 @@ export class PodContainerPort extends React.Component<Props> {
<span title="Open in a browser" onClick={() => this.portForward()}> <span title="Open in a browser" onClick={() => this.portForward()}>
{text} {text}
</span> </span>
<Button onClick={() => portForwardAction()}> {this.isPortForwarded ? "Stop" : "Forward..."} </Button> <Button primary onClick={() => portForwardAction()}> {this.isPortForwarded ? "Stop" : "Forward..."} </Button>
{this.waiting && ( {this.waiting && (
<Spinner /> <Spinner />
)} )}

View File

@ -30,8 +30,8 @@
display: block; display: block;
font-family: $font-monospace; font-family: $font-monospace;
font-size: 90%; font-size: 90%;
background: $colorVague; background: var(--colorVague);
color: $textColorSecondary; color: var(--textColorSecondary);
border-radius: $radius; border-radius: $radius;
padding: .2em .4em; padding: .2em .4em;
margin-top: $margin; margin-top: $margin;
@ -42,7 +42,7 @@
margin-bottom: $margin; margin-bottom: $margin;
.StatusBrick { .StatusBrick {
background: $colorTerminated; background: var(--colorTerminated);
margin-right: $margin; margin-right: $margin;
@include pod-status-bgs; @include pod-status-bgs;
@ -54,7 +54,7 @@
} }
.status { .status {
color: $colorTerminated; color: var(--colorTerminated);
@include pod-status-colors; @include pod-status-colors;
} }

View File

@ -55,7 +55,7 @@
align-self: center; align-self: center;
.LineProgress { .LineProgress {
color: $lensBlue; color: var(--blue);
} }
} }
@ -63,7 +63,7 @@
align-self: center; align-self: center;
.LineProgress { .LineProgress {
color: $lensMagenta; color: var(--magenta);
} }
} }

View File

@ -69,7 +69,7 @@ export class Pods extends React.Component<Props> {
formatters: { formatters: {
tableView: true, tableView: true,
}, },
children: Object.keys(state).map(status => ( children: Object.keys(state).map((status: keyof typeof state) => (
<Fragment key={status}> <Fragment key={status}>
<div className="title"> <div className="title">
{name} <span className="text-secondary">({status}{ready ? ", ready" : ""})</span> {name} <span className="text-secondary">({status}{ready ? ", ready" : ""})</span>

Some files were not shown because too many files have changed in this diff Show More