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

Merge branch 'master' into tag-exposed-extension-stores-as-beta

This commit is contained in:
Jari Kolehmainen 2020-12-03 15:36:12 +02:00 committed by GitHub
commit bb6a7dcf7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
425 changed files with 2738 additions and 378 deletions

View File

@ -41,10 +41,12 @@ jobs:
displayName: Generate npm package
- script: make -j2 build-extensions
displayName: Build bundled extensions
- script: make integration-win
displayName: Run integration tests
- script: make test
displayName: Run tests
- script: make test-extensions
displayName: Run In-tree Extension tests
- script: make integration-win
displayName: Run integration tests
- script: make build
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"
displayName: Build

View File

@ -4,6 +4,7 @@ module.exports = {
ignorePatterns: [
"**/node_modules/**/*",
"**/dist/**/*",
"**/static/**/*",
],
settings: {
react: {
@ -49,6 +50,15 @@ module.exports = {
"object-shorthand": "error",
"prefer-template": "error",
"template-curly-spacing": "error",
"padding-line-between-statements": [
"error",
{ "blankLine": "always", "prev": "*", "next": "return" },
{ "blankLine": "always", "prev": "*", "next": "block-like" },
{ "blankLine": "always", "prev": "*", "next": "function" },
{ "blankLine": "always", "prev": "*", "next": "class" },
{ "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" },
{ "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"]},
]
}
},
{
@ -94,6 +104,15 @@ module.exports = {
"object-shorthand": "error",
"prefer-template": "error",
"template-curly-spacing": "error",
"padding-line-between-statements": [
"error",
{ "blankLine": "always", "prev": "*", "next": "return" },
{ "blankLine": "always", "prev": "*", "next": "block-like" },
{ "blankLine": "always", "prev": "*", "next": "function" },
{ "blankLine": "always", "prev": "*", "next": "class" },
{ "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" },
{ "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"]},
]
},
},
{
@ -146,6 +165,15 @@ module.exports = {
"object-shorthand": "error",
"prefer-template": "error",
"template-curly-spacing": "error",
"padding-line-between-statements": [
"error",
{ "blankLine": "always", "prev": "*", "next": "return" },
{ "blankLine": "always", "prev": "*", "next": "block-like" },
{ "blankLine": "always", "prev": "*", "next": "function" },
{ "blankLine": "always", "prev": "*", "next": "class" },
{ "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" },
{ "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"]},
]
},
}
]

View File

@ -17,14 +17,15 @@ export async function generateTrayIcon(
outputFilename += shouldUseDarkColors ? "_dark" : "";
dpiSuffix = dpiSuffix !== "1x" ? `@${dpiSuffix}` : "";
const pngIconDestPath = path.resolve(outputFolder, `${outputFilename}${dpiSuffix}.png`);
try {
// Modify .SVG colors
const trayIconColor = shouldUseDarkColors ? "white" : "black";
const svgDom = await jsdom.JSDOM.fromFile(svgIconPath);
const svgRoot = svgDom.window.document.body.getElementsByTagName("svg")[0];
svgRoot.innerHTML += `<style>* {fill: ${trayIconColor} !important;}</style>`;
const svgIconBuffer = Buffer.from(svgRoot.outerHTML);
// Resize and convert to .PNG
const pngIconBuffer: Buffer = await sharp(svgIconBuffer)
.resize({ width: pixelSize, height: pixelSize })
@ -45,6 +46,7 @@ const iconSizes: Record<string, number> = {
"2x": 32,
"3x": 48,
};
Object.entries(iconSizes).forEach(([dpiSuffix, pixelSize]) => {
generateTrayIcon({ dpiSuffix, pixelSize, shouldUseDarkColors: false });
generateTrayIcon({ dpiSuffix, pixelSize, shouldUseDarkColors: true });

View File

@ -15,6 +15,7 @@ class KubectlDownloader {
constructor(clusterVersion: string, platform: string, arch: string, target: string) {
this.kubectlVersion = clusterVersion;
const binaryName = platform === "windows" ? "kubectl.exe" : "kubectl";
this.url = `https://storage.googleapis.com/kubernetes-release/release/v${this.kubectlVersion}/bin/${platform}/${arch}/${binaryName}`;
this.dirname = path.dirname(target);
this.path = target;
@ -30,16 +31,20 @@ class KubectlDownloader {
if (response.headers["etag"]) {
return response.headers["etag"].replace(/"/g, "");
}
return "";
}
public async checkBinary() {
const exists = await pathExists(this.path);
if (exists) {
const hash = md5File.sync(this.path);
const etag = await this.urlEtag();
if(hash == etag) {
console.log("Kubectl md5sum matches the remote etag");
return true;
}
@ -52,13 +57,16 @@ class KubectlDownloader {
public async downloadKubectl() {
const exists = await this.checkBinary();
if(exists) {
console.log("Already exists and is valid");
return;
}
await ensureDir(path.dirname(this.path), 0o755);
const file = fs.createWriteStream(this.path);
console.log(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`);
const requestOpts: request.UriOptions & request.CoreOptions = {
uri: this.url,
@ -78,6 +86,7 @@ class KubectlDownloader {
fs.unlink(this.path, () => {});
throw(error);
});
return new Promise((resolve, reject) => {
file.on("close", () => {
console.log("kubectl binary download closed");
@ -103,6 +112,7 @@ const downloads = [
downloads.forEach((dlOpts) => {
console.log(dlOpts);
const downloader = new KubectlDownloader(downloadVersion, dlOpts.platform, dlOpts.arch, dlOpts.target);
console.log(`Downloading: ${JSON.stringify(dlOpts)}`);
downloader.downloadKubectl().then(() => downloader.checkBinary().then(() => console.log("Download complete")));
});

View File

@ -2,9 +2,11 @@ const { notarize } = require("electron-notarize");
exports.default = async function notarizing(context) {
const { electronPlatformName, appOutDir } = context;
if (electronPlatformName !== "darwin") {
return;
}
if (!process.env.APPLEID || !process.env.APPLEIDPASS) {
return;
}

View File

Before

Width:  |  Height:  |  Size: 478 B

After

Width:  |  Height:  |  Size: 478 B

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -225,7 +225,7 @@ import { HelpIcon, HelpPage } from "./page"
import React from "react"
export default class HelpExtension extends LensRendererExtension {
clusterPages = [
globalPages = [
{
id: "help",
components: {
@ -379,6 +379,74 @@ The `uninstall()` method is implemented in the example above by utilizing the [`
The `updateStatus()` method is implemented above by using the [`K8sApi`](tbd) as well, this time to get information from the `example-pod` pod, in particular to determine if it is installed, what version is associated with it, and if it can be upgraded. How the status is updated for a specific cluster feature is up to the implementation.
### `appPreferences`
The Preferences page is a built-in global page. Extensions can add custom preferences to the Preferences page, thus providing a single location for users to configure global options, for Lens and extensions alike. The following example demonstrates adding a custom preference:
``` typescript
import { LensRendererExtension } from "@k8slens/extensions";
import { ExamplePreference, ExamplePreferenceHint, ExamplePreferenceInput } from "./src/example-preference";
import { observable } from "mobx";
import React from "react";
export default class ExampleRendererExtension extends LensRendererExtension {
@observable preference: ExamplePreference = { enabled: false };
appPreferences = [
{
title: "Example Preferences",
components: {
Input: () => <ExamplePreferenceInput preference={this.preference}/>,
Hint: () => <ExamplePreferenceHint/>
}
}
];
}
```
App preferences are objects matching the `AppPreferenceRegistration` interface. The `title` field specifies the text to show as the heading on the Preferences page. The `components` field specifies two `React.Component` objects defining the interface for the preference. `Input` should specify an interactive input element for your preference and `Hint` should provide descriptive information for the preference, which is shown below the `Input` element. `ExamplePreferenceInput` expects its React props set to an `ExamplePreference` instance, which is how `ExampleRendererExtension` handles the state of the preference input. `ExampleRendererExtension` has the field `preference`, which is provided to `ExamplePreferenceInput` when it is created. In this example `ExamplePreferenceInput`, `ExamplePreferenceHint`, and `ExamplePreference` are defined in `./src/example-preference.tsx`:
``` typescript
import { Component } from "@k8slens/extensions";
import { observer } from "mobx-react";
import React from "react";
export type ExamplePreference = {
enabled: boolean;
}
@observer
export class ExamplePreferenceInput extends React.Component<{preference: ExamplePreference}, {}> {
render() {
const { preference } = this.props;
return (
<Component.Checkbox
label="I understand appPreferences"
value={preference.enabled}
onChange={v => { preference.enabled = v; }}
/>
);
}
}
export class ExamplePreferenceHint extends React.Component {
render() {
return (
<span>This is an example of an appPreference for extensions.</span>
);
}
}
```
`ExamplePreferenceInput` implements a simple checkbox (using Lens' `Component.Checkbox`). It provides `label` as the text to display next to the checkbox and an `onChange` function, which reacts to the checkbox state change. The checkbox's `value` is initially set to `preference.enabled`. `ExamplePreferenceInput` is defined with React props of `ExamplePreference` type, which has a single field, `enabled`. This is used to indicate the state of the preference, and is bound to the checkbox state in `onChange`. `ExamplePreferenceHint` is a simple text span. Note that the input and the hint could comprise of more sophisticated elements, according to the needs of the extension.
Note that the above example introduces decorators `observable` and `observer` from the [`mobx`](https://mobx.js.org/README.html) and [`mobx-react`](https://github.com/mobxjs/mobx-react#mobx-react) packages. `mobx` simplifies state management and without it this example would not visually update the checkbox properly when the user activates it. [Lens uses `mobx` extensively for state management](../working-with-mobx) of its own UI elements and it is recommended that extensions rely on it too. Alternatively, React's state management can be used instead, though `mobx` is typically simpler to use.
Also note that an extension's state data can be managed using an `ExtensionStore` object, which conveniently handles persistence and synchronization. The example above defined an `ExamplePreference` type to hold the extension's state to simplify the code for this guide, but it is recommended to manage your extension's state data using [`ExtensionStore`](../stores#extensionstore)
*********************************************************************
WIP below!
@ -386,62 +454,36 @@ WIP below!
### `appPreferences`
The Preferences page is essentially a global page. Extensions can add custom preferences to the Preferences page, thus providing a single location for users to configure global, for Lens and extensions alike.
``` typescript
import React from "react"
import { LensRendererExtension } from "@k8slens/extensions"
import { myCustomPreferencesStore } from "./src/my-custom-preferences-store"
import { MyCustomPreferenceHint, MyCustomPreferenceInput } from "./src/my-custom-preference"
export default class ExampleRendererExtension extends LensRendererExtension {
appPreferences = [
{
title: "My Custom Preference",
components: {
Hint: () => <MyCustomPreferenceHint/>,
Input: () => <MyCustomPreferenceInput store={myCustomPreferencesStore}/>
}
}
];
}
```
### `statusBarItems`
The Status bar is the blue strip along the bottom of the Lens UI. Status bar items are `React.ReactNode` types, which can be used to convey status information, or act as a link to a global page.
The Status bar is the blue strip along the bottom of the Lens UI. Status bar items are `React.ReactNode` types, which can be used to convey status information, or act as a link to a global page, or even an external page.
The following example adds a status bar item definition, as well as a global page definition, to a `LensRendererExtension` subclass, and configures the status bar item to navigate to the global upon a mouse click:
The following example adds a status bar item definition, as well as a global page definition, to a `LensRendererExtension` subclass, and configures the status bar item to navigate to the global page upon a mouse click:
``` typescript
import { LensRendererExtension, Navigation } from '@k8slens/extensions';
import { MyStatusBarIcon, MyPage } from './page';
import { LensRendererExtension } from '@k8slens/extensions';
import { HelpIcon, HelpPage } from "./page"
import React from 'react';
export default class ExtensionRenderer extends LensRendererExtension {
export default class HelpExtension extends LensRendererExtension {
globalPages = [
{
path: "/my-extension-path",
hideInMenu: true,
id: "help",
components: {
Page: () => <MyPage extension={this} />,
},
},
Page: () => <HelpPage extension={this}/>,
}
}
];
statusBarItems = [
{
item: (
<div
className="flex align-center gaps hover-highlight"
onClick={() => Navigation.navigate(this.globalPages[0].path)}
className="flex align-center gaps"
onClick={() => this.navigate("help")}
>
<MyStatusBarIcon />
<span>My Status Bar Item</span>
<HelpIcon />
My Status Bar Item
</div>
),
},

View File

@ -1,3 +1,11 @@
---
WIP
---
# Stores
## ClusterStore
## WorkspaceStore
## ExtensionStore

View File

@ -3,21 +3,24 @@
The features that Lens includes out-of-the-box are just the start.
Lens extensions let you add new features to your installation to support your workflow.
Rich extensibility model lets extension authors plug directly into the Lens UI and contribute functionality through the same APIs used by Lens itself.
The start using Lens Extensions go to **File** (or **Lens** on macOS) > **Extensions** in the application menu.
This is the `Extensions` management page where all the management of the extensions you want to use is done.
![Extensions](images/extensions.png)
## Installing an Extension
You can install a dowloaded extension .tgz package by going to **File** > **Extensions** (**Lens** > **Extensions** on Mac). Alternatively you can point an URL to .tgz file. An installed extension is enabled automatically.
There are three ways to install extensions.
If you have the extension as a `.tgz` file then dragging and dropping it in the extension management page will install it for you.
If it is hosted on the web, you can paste the URL and click `Install` and Lens will download and install it.
The third way is to move the extension into your `~/.k8slens/extensions` (or `C:\Users\<user>\.k8slens\extensions`) folder and Lens will automatically detect it and install the extension.
## Enabling an Extension
## Enabling or Disabling an Extension
Go to **File** > **Extensions** (**Lens** > **Extensions** on Mac) and click "Enable" button.
## Disabling an Extension
Go to **File** > **Extensions** (**Lens** > **Extensions** on Mac) and click "Disable" button.
Go to the extension management page and click either the `Enable` or `Disable` buttons.
Extensions will be enabled by default when you first install them.
A disabled extension is not loaded by Lens and is not run.
## Uninstalling an Extension
Go to **File** > **Extensions** (**Lens** > **Extensions** on Mac) and click "Uninstall" button.
If, for whatever reason, you wish to remove the installation of an extension simple click the `Uninstall` button. This will remove all the files that Lens would need to run the extension.

View File

@ -1,5 +1,5 @@
{
"name": "extension-example",
"name": "example-extension",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,

View File

@ -10,6 +10,7 @@ export function ExampleIcon(props: Component.IconProps) {
export class ExamplePage extends React.Component<{ extension: LensRendererExtension }> {
deactivate = () => {
const { extension } = this.props;
extension.disable();
};
@ -17,6 +18,7 @@ export class ExamplePage extends React.Component<{ extension: LensRendererExtens
const doodleStyle = {
width: "200px"
};
return (
<div className="flex column gaps align-flex-start">
<div style={doodleStyle}><CoffeeDoodle accent="#3d90ce" /></div>

View File

@ -4,10 +4,12 @@ export function resolveStatus(object: K8sApi.KubeObject): K8sApi.KubeObjectStatu
const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi);
const events = (eventStore as K8sApi.EventStore).getEventsByObject(object);
const warnings = events.filter(evt => evt.isWarning());
if (!events.length || !warnings.length) {
return null;
}
const event = [...warnings, ...events][0]; // get latest event
return {
level: K8sApi.KubeObjectStatusLevel.WARNING,
text: `${event.message}`,
@ -22,10 +24,12 @@ export function resolveStatusForPods(pod: K8sApi.Pod): K8sApi.KubeObjectStatus {
const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi);
const events = (eventStore as K8sApi.EventStore).getEventsByObject(pod);
const warnings = events.filter(evt => evt.isWarning());
if (!events.length || !warnings.length) {
return null;
}
const event = [...warnings, ...events][0]; // get latest event
return {
level: K8sApi.KubeObjectStatusLevel.WARNING,
text: `${event.message}`,
@ -37,13 +41,16 @@ export function resolveStatusForCronJobs(cronJob: K8sApi.CronJob): K8sApi.KubeOb
const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi);
let events = (eventStore as K8sApi.EventStore).getEventsByObject(cronJob);
const warnings = events.filter(evt => evt.isWarning());
if (cronJob.isNeverRun()) {
events = events.filter(event => event.reason != "FailedNeedsStart");
}
if (!events.length || !warnings.length) {
return null;
}
const event = [...warnings, ...events][0]; // get latest event
return {
level: K8sApi.KubeObjectStatusLevel.WARNING,
text: `${event.message}`,

View File

@ -53,6 +53,7 @@ export class MetricsFeature extends ClusterFeature.Feature {
// Check if there are storageclasses
const storageClassApi = K8sApi.forCluster(cluster, K8sApi.StorageClass);
const scs = await storageClassApi.list();
this.templateContext.persistence.enabled = scs.some(sc => (
sc.metadata?.annotations?.["storageclass.kubernetes.io/is-default-class"] === "true" ||
sc.metadata?.annotations?.["storageclass.beta.kubernetes.io/is-default-class"] === "true"
@ -69,6 +70,7 @@ export class MetricsFeature extends ClusterFeature.Feature {
try {
const statefulSet = K8sApi.forCluster(cluster, K8sApi.StatefulSet);
const prometheus = await statefulSet.get({name: "prometheus", namespace: "lens-metrics"});
if (prometheus?.kind) {
this.status.installed = true;
this.status.currentVersion = prometheus.spec.template.spec.containers[0].image.split(":")[1];

View File

@ -6,6 +6,7 @@ export interface NodeMenuProps extends Component.KubeObjectMenuProps<K8sApi.Node
export function NodeMenu(props: NodeMenuProps) {
const { object: node, toolbar } = props;
if (!node) return null;
const nodeName = node.getName();
@ -35,6 +36,7 @@ export function NodeMenu(props: NodeMenuProps) {
const drain = () => {
const command = `kubectl drain ${nodeName} --delete-local-data --ignore-daemonsets --force`;
Component.ConfirmDialog.open({
ok: () => sendToTerminal(command),
labelOk: `Drain Node`,

View File

@ -8,6 +8,7 @@ export class PodLogsMenu extends React.Component<PodLogsMenuProps> {
showLogs(container: K8sApi.IPodContainer) {
Navigation.hideDetails();
const pod = this.props.object;
Component.createPodLogsTab({
pod,
containers: pod.getContainers(),
@ -22,7 +23,9 @@ export class PodLogsMenu extends React.Component<PodLogsMenuProps> {
const { object: pod, toolbar } = this.props;
const containers = pod.getAllContainers();
const statuses = pod.getContainerStatuses();
if (!containers.length) return null;
return (
<Component.MenuItem onClick={Util.prevDefault(() => this.showLogs(containers[0]))}>
<Component.Icon material="subject" title="Logs" interactive={toolbar}/>
@ -40,6 +43,7 @@ export class PodLogsMenu extends React.Component<PodLogsMenuProps> {
className={Util.cssNames(Object.keys(status.state)[0], { ready: status.ready })}
/>
) : null;
return (
<Component.MenuItem key={name} onClick={Util.prevDefault(() => this.showLogs(container))} className="flex align-center">
{brick}

View File

@ -12,9 +12,11 @@ export class PodShellMenu extends React.Component<PodShellMenuProps> {
const { object: pod } = this.props;
const containerParam = container ? `-c ${container}` : "";
let command = `kubectl exec -i -t -n ${pod.getNs()} ${pod.getName()} ${containerParam} "--"`;
if (window.navigator.platform !== "Win32") {
command = `exec ${command}`;
}
if (pod.getSelectedNodeOs() === "windows") {
command = `${command} powershell`;
} else {
@ -34,7 +36,9 @@ export class PodShellMenu extends React.Component<PodShellMenuProps> {
render() {
const { object, toolbar } = this.props;
const containers = object.getRunningContainers();
if (!containers.length) return null;
return (
<Component.MenuItem onClick={Util.prevDefault(() => this.execShell(containers[0].name))}>
<Component.Icon svg="ssh" interactive={toolbar} title="Pod shell"/>
@ -46,6 +50,7 @@ export class PodShellMenu extends React.Component<PodShellMenuProps> {
{
containers.map(container => {
const { name } = container;
return (
<Component.MenuItem key={name} onClick={Util.prevDefault(() => this.execShell(name))} className="flex align-center">
<Component.StatusBrick/>

View File

@ -7,6 +7,7 @@ import { TelemetryPreferencesStore } from "./telemetry-preferences-store";
export class TelemetryPreferenceInput extends React.Component<{telemetry: TelemetryPreferencesStore}, {}> {
render() {
const { telemetry } = this.props;
return (
<Component.Checkbox
label="Allow telemetry & usage tracking"

View File

@ -29,6 +29,7 @@ export class Tracker extends Util.Singleton {
this.anonymousId = machineIdSync();
this.os = this.resolveOS();
this.userAgent = `Lens ${App.version} (${this.os})`;
try {
this.visitor = ua(Tracker.GA_ID, this.anonymousId, { strictCidFormat: false });
} catch (error) {
@ -49,18 +50,22 @@ export class Tracker extends Util.Singleton {
const handler = (ev: EventBus.AppEvent) => {
this.event(ev.name, ev.action, ev.params);
};
this.eventHandlers.push(handler);
EventBus.appEventBus.addListener(handler);
}
watchExtensions() {
let previousExtensions = App.getEnabledExtensions();
this.disposers.push(reaction(() => App.getEnabledExtensions(), (currentExtensions) => {
const removedExtensions = previousExtensions.filter(x => !currentExtensions.includes(x));
removedExtensions.forEach(ext => {
this.event("extension", "disable", { extension: ext });
});
const newExtensions = currentExtensions.filter(x => !previousExtensions.includes(x));
newExtensions.forEach(ext => {
this.event("extension", "enable", { extension: ext });
});
@ -82,6 +87,7 @@ export class Tracker extends Util.Singleton {
for (const handler of this.eventHandlers) {
EventBus.appEventBus.removeListener(handler);
}
if (this.reportInterval) {
clearInterval(this.reportInterval);
}
@ -125,12 +131,14 @@ export class Tracker extends Util.Singleton {
protected resolveOS() {
let os = "";
if (App.isMac) {
os = "MacOS";
} else if(App.isWindows) {
os = "Windows";
} else if (App.isLinux) {
os = "Linux";
if (App.isSnap) {
os += "; Snap";
} else {
@ -139,12 +147,14 @@ export class Tracker extends Util.Singleton {
} else {
os = "Unknown";
}
return os;
}
protected async event(eventCategory: string, eventAction: string, otherParams = {}) {
try {
const allowed = await this.isTelemetryAllowed();
if (!allowed) {
return;
}

View File

@ -14,9 +14,7 @@ jest.setTimeout(60000);
describe("Lens integration tests", () => {
const TEST_NAMESPACE = "integration-tests";
const BACKSPACE = "\uE003";
let app: Application;
const appStart = async () => {
app = util.setup();
await app.start();
@ -25,19 +23,19 @@ describe("Lens integration tests", () => {
await app.client.windowByIndex(0);
await app.client.waitUntilWindowLoaded();
};
const clickWhatsNew = async (app: Application) => {
await app.client.waitUntilTextExists("h1", "What's new?");
await app.client.click("button.primary");
await app.client.waitUntilTextExists("h1", "Welcome");
};
const minikubeReady = (): boolean => {
// determine if minikube is running
{
const { status } = spawnSync("minikube status", { shell: true });
if (status !== 0) {
console.warn("minikube not running");
return false;
}
}
@ -45,6 +43,7 @@ describe("Lens integration tests", () => {
// Remove TEST_NAMESPACE if it already exists
{
const { status } = spawnSync(`minikube kubectl -- get namespace ${TEST_NAMESPACE}`, { shell: true });
if (status === 0) {
console.warn(`Removing existing ${TEST_NAMESPACE} namespace`);
@ -52,8 +51,10 @@ describe("Lens integration tests", () => {
`minikube kubectl -- delete namespace ${TEST_NAMESPACE}`,
{ shell: true },
);
if (status !== 0) {
console.warn(`Error removing ${TEST_NAMESPACE} namespace: ${stderr.toString()}`);
return false;
}
@ -86,6 +87,7 @@ describe("Lens integration tests", () => {
describe("preferences page", () => {
it('shows "preferences"', async () => {
const appName: string = process.platform === "darwin" ? "Lens" : "File";
await app.electron.ipcRenderer.send("test-menu-item-click", appName, "Preferences");
await app.client.waitUntilTextExists("h2", "Preferences");
});
@ -153,13 +155,13 @@ describe("Lens integration tests", () => {
await app.client.waitUntilTextExists("div", "Select kubeconfig file");
await app.client.click("div.Select__control"); // show the context drop-down list
await app.client.waitUntilTextExists("div", "minikube");
if (!await app.client.$("button.primary").isEnabled()) {
await app.client.click("div.minikube"); // select minikube context
} // else the only context, which must be 'minikube', is automatically selected
await app.client.click("div.Select__control"); // hide the context drop-down list (it might be obscuring the Add cluster(s) button)
await app.client.click("button.primary"); // add minikube cluster
};
const waitForMinikubeDashboard = async (app: Application) => {
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
await app.client.waitForExist(`iframe[name="minikube"]`);
@ -169,7 +171,6 @@ describe("Lens integration tests", () => {
util.describeIf(ready)("cluster tests", () => {
let clusterAdded = false;
const addCluster = async () => {
await clickWhatsNew(app);
await addMinikubeCluster(app);
@ -443,6 +444,7 @@ describe("Lens integration tests", () => {
expectedText: "Custom Resources"
}]
}];
tests.forEach(({ drawer = "", drawerId = "", pages }) => {
if (drawer !== "") {
it(`shows ${drawer} drawer`, async () => {
@ -458,6 +460,7 @@ describe("Lens integration tests", () => {
await app.client.waitUntilTextExists(expectedSelector, expectedText);
});
});
if (drawer !== "") {
// hide the drawer
it(`hides ${drawer} drawer`, async () => {

View File

@ -30,7 +30,9 @@ type AsyncPidGetter = () => Promise<number>;
export async function tearDown(app: Application) {
const pid = await (app.mainProcess.pid as any as AsyncPidGetter)();
await app.stop();
try {
process.kill(pid, "SIGKILL");
} catch (e) {

View File

@ -1594,6 +1594,7 @@ msgstr "Names"
#: src/renderer/components/dock/upgrade-chart.tsx:98
#: src/renderer/components/item-object-list/page-filters-select.tsx:57
#: src/renderer/components/kube-object/kube-object-meta.tsx:23
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:144
msgid "Namespace"
msgstr "Namespace"
@ -2003,6 +2004,10 @@ msgstr "Read-only Root Filesystem"
msgid "Readiness"
msgstr "Readiness"
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:145
msgid "Ready"
msgstr "Ready"
#: src/renderer/components/+events/event-details.tsx:32
#: src/renderer/components/+workloads-pods/pod-details-container.tsx:25
msgid "Reason"

View File

@ -1585,6 +1585,7 @@ msgstr ""
#: src/renderer/components/dock/upgrade-chart.tsx:98
#: src/renderer/components/item-object-list/page-filters-select.tsx:57
#: src/renderer/components/kube-object/kube-object-meta.tsx:23
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:144
msgid "Namespace"
msgstr ""
@ -1986,6 +1987,10 @@ msgstr ""
msgid "Readiness"
msgstr ""
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:145
msgid "Ready"
msgstr ""
#: src/renderer/components/+events/event-details.tsx:32
#: src/renderer/components/+workloads-pods/pod-details-container.tsx:25
msgid "Reason"

View File

@ -1595,6 +1595,7 @@ msgstr ""
#: src/renderer/components/dock/upgrade-chart.tsx:98
#: src/renderer/components/item-object-list/page-filters-select.tsx:57
#: src/renderer/components/kube-object/kube-object-meta.tsx:23
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:144
msgid "Namespace"
msgstr "Namespace"
@ -2004,6 +2005,10 @@ msgstr ""
msgid "Readiness"
msgstr "Готовность"
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:145
msgid "Ready"
msgstr "Готовы"
#: src/renderer/components/+events/event-details.tsx:32
#: src/renderer/components/+workloads-pods/pod-details-container.tsx:25
msgid "Reason"

View File

@ -2,7 +2,7 @@
"name": "kontena-lens",
"productName": "Lens",
"description": "Lens - The Kubernetes IDE",
"version": "4.0.0-rc.1",
"version": "4.0.0-rc.2",
"main": "static/build/main.js",
"copyright": "© 2020, Mirantis, Inc.",
"license": "MIT",
@ -37,7 +37,7 @@
"download:kubectl": "yarn run ts-node build/download_kubectl.ts",
"download:helm": "yarn run ts-node build/download_helm.ts",
"build:tray-icons": "yarn run ts-node build/build_tray_icon.ts",
"lint": "yarn run eslint $@ --ext js,ts,tsx --max-warnings=0 src/ integration/ __mocks__/ build/ extensions/",
"lint": "yarn run eslint $@ --ext js,ts,tsx --max-warnings=0 .",
"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",
"verify-docs": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -v ${PWD}:/docs mkdocs-serve-local:latest build --strict",

View File

@ -7,6 +7,20 @@ import { workspaceStore } from "../workspace-store";
const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png");
jest.mock("electron", () => {
return {
app: {
getVersion: () => "99.99.99",
getPath: () => "tmp",
getLocale: () => "en"
},
ipcMain: {
handle: jest.fn(),
on: jest.fn()
}
};
});
let clusterStore: ClusterStore;
describe("empty config", () => {
@ -17,8 +31,10 @@ describe("empty config", () => {
"lens-cluster-store.json": JSON.stringify({})
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
});
@ -45,13 +61,16 @@ describe("empty config", () => {
it("adds new cluster to store", async () => {
const storedCluster = clusterStore.getById("foo");
expect(storedCluster.id).toBe("foo");
expect(storedCluster.preferences.terminalCWD).toBe("/tmp");
expect(storedCluster.preferences.icon).toBe("data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5");
expect(storedCluster.enabled).toBe(true);
});
it("adds cluster to default workspace", () => {
const storedCluster = clusterStore.getById("foo");
expect(storedCluster.workspace).toBe("default");
});
@ -99,6 +118,7 @@ describe("empty config", () => {
it("gets clusters by workspaces", () => {
const wsClusters = clusterStore.getByWorkspaceId("workstation");
const defaultClusters = clusterStore.getByWorkspaceId("default");
expect(defaultClusters.length).toBe(0);
expect(wsClusters.length).toBe(2);
expect(wsClusters[0].id).toBe("prod");
@ -107,6 +127,7 @@ describe("empty config", () => {
it("check if cluster's kubeconfig file saved", () => {
const file = ClusterStore.embedCustomKubeConfig("boo", "kubeconfig");
expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig");
});
@ -114,6 +135,7 @@ describe("empty config", () => {
clusterStore.swapIconOrders("workstation", 1, 1);
const clusters = clusterStore.getByWorkspaceId("workstation");
expect(clusters[0].id).toBe("prod");
expect(clusters[0].preferences.iconOrder).toBe(0);
expect(clusters[1].id).toBe("dev");
@ -124,6 +146,7 @@ describe("empty config", () => {
clusterStore.swapIconOrders("workstation", 0, 1);
const clusters = clusterStore.getByWorkspaceId("workstation");
expect(clusters[0].id).toBe("dev");
expect(clusters[0].preferences.iconOrder).toBe(0);
expect(clusters[1].id).toBe("prod");
@ -170,14 +193,17 @@ describe("config with existing clusters", () => {
kubeConfig: "foo",
contextName: "foo",
preferences: { terminalCWD: "/foo" },
workspace: "foo"
workspace: "foo",
ownerRef: "foo"
},
]
})
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
});
@ -187,6 +213,7 @@ describe("config with existing clusters", () => {
it("allows to retrieve a cluster", () => {
const storedCluster = clusterStore.getById("cluster1");
expect(storedCluster.id).toBe("cluster1");
expect(storedCluster.preferences.terminalCWD).toBe("/foo");
});
@ -194,13 +221,16 @@ describe("config with existing clusters", () => {
it("allows to delete a cluster", () => {
clusterStore.removeById("cluster2");
const storedCluster = clusterStore.getById("cluster1");
expect(storedCluster).toBeTruthy();
const storedCluster2 = clusterStore.getById("cluster2");
expect(storedCluster2).toBeUndefined();
});
it("allows getting all of the clusters", async () => {
const storedClusters = clusterStore.clustersList;
expect(storedClusters.length).toBe(3);
expect(storedClusters[0].id).toBe("cluster1");
expect(storedClusters[0].preferences.terminalCWD).toBe("/foo");
@ -208,6 +238,13 @@ describe("config with existing clusters", () => {
expect(storedClusters[1].preferences.terminalCWD).toBe("/foo2");
expect(storedClusters[2].id).toBe("cluster3");
});
it("marks owned cluster disabled by default", () => {
const storedClusters = clusterStore.clustersList;
expect(storedClusters[0].enabled).toBe(true);
expect(storedClusters[2].enabled).toBe(false);
});
});
describe("pre 2.0 config with an existing cluster", () => {
@ -225,8 +262,10 @@ describe("pre 2.0 config with an existing cluster", () => {
})
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
});
@ -236,6 +275,7 @@ describe("pre 2.0 config with an existing cluster", () => {
it("migrates to modern format with kubeconfig in a file", async () => {
const config = clusterStore.clustersList[0].kubeConfigPath;
expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content");
});
});
@ -257,8 +297,10 @@ describe("pre 2.6.0 config with a cluster that has arrays in auth config", () =>
})
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
});
@ -270,6 +312,7 @@ describe("pre 2.6.0 config with a cluster that has arrays in auth config", () =>
const file = clusterStore.clustersList[0].kubeConfigPath;
const config = fs.readFileSync(file, "utf8");
const kc = yaml.safeLoad(config);
expect(kc.users[0].user["auth-provider"].config["access-token"]).toBe("should be string");
expect(kc.users[0].user["auth-provider"].config["expiry"]).toBe("should be string");
});
@ -297,8 +340,10 @@ describe("pre 2.6.0 config with a cluster icon", () => {
"icon_path": testDataIcon,
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
});
@ -308,6 +353,7 @@ describe("pre 2.6.0 config with a cluster icon", () => {
it("moves the icon into preferences", async () => {
const storedClusterData = clusterStore.clustersList[0];
expect(storedClusterData.hasOwnProperty("icon")).toBe(false);
expect(storedClusterData.preferences.hasOwnProperty("icon")).toBe(true);
expect(storedClusterData.preferences.icon.startsWith("data:;base64,")).toBe(true);
@ -334,8 +380,10 @@ describe("for a pre 2.7.0-beta.0 config without a workspace", () => {
})
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
});
@ -345,6 +393,7 @@ describe("for a pre 2.7.0-beta.0 config without a workspace", () => {
it("adds cluster to default workspace", async () => {
const storedClusterData = clusterStore.clustersList[0];
expect(storedClusterData.workspace).toBe("default");
});
});
@ -374,8 +423,10 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => {
"icon_path": testDataIcon,
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
});
@ -385,11 +436,13 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => {
it("migrates to modern format with kubeconfig in a file", async () => {
const config = clusterStore.clustersList[0].kubeConfigPath;
expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content");
});
it("migrates to modern format with icon not in file", async () => {
const { icon } = clusterStore.clustersList[0].preferences;
expect(icon.startsWith("data:;base64,")).toBe(true);
});
});

View File

@ -4,6 +4,7 @@ describe("event bus tests", () => {
describe("emit", () => {
it("emits an event", () => {
let event: AppEvent = null;
appEventBus.addListener((data) => {
event = data;
});

View File

@ -5,7 +5,6 @@
import { SearchStore } from "../search-store";
let searchStore: SearchStore = null;
const logs = [
"1:M 30 Oct 2020 16:17:41.553 # Connection with replica 172.17.0.12:6379 lost",
"1:M 30 Oct 2020 16:17:41.623 * Replica 172.17.0.12:6379 asks for synchronization",
@ -64,6 +63,7 @@ describe("search store tests", () => {
it("escapes string for using in regex", () => {
const regex = searchStore.escapeRegex("some.interesting-query\\#?()[]");
expect(regex).toBe("some\\.interesting\\-query\\\\\\#\\?\\(\\)\\[\\]");
});

View File

@ -59,6 +59,7 @@ describe("user store tests", () => {
it("correctly resets theme to default value", async () => {
const us = UserStore.getInstance<UserStore>();
us.isLoaded = true;
us.preferences.colorTheme = "some other theme";

View File

@ -6,6 +6,10 @@ jest.mock("electron", () => {
getVersion: () => "99.99.99",
getPath: () => "tmp",
getLocale: () => "en"
},
ipcMain: {
handle: jest.fn(),
on: jest.fn()
}
};
});
@ -40,7 +44,6 @@ describe("workspace store tests", () => {
it("can update workspace description", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
const workspace = ws.addWorkspace(new Workspace({
id: "foobar",
name: "foobar",
@ -60,7 +63,10 @@ describe("workspace store tests", () => {
name: "foobar",
}));
expect(ws.getById("123").name).toBe("foobar");
const workspace = ws.getById("123");
expect(workspace.name).toBe("foobar");
expect(workspace.enabled).toBe(true);
});
it("cannot set a non-existent workspace to be active", () => {

View File

@ -55,6 +55,7 @@ export abstract class BaseStore<T = any> extends Singleton {
if (this.params.autoLoad) {
await this.load();
}
if (this.params.syncEnabled) {
await this.whenLoaded;
this.enableSync();
@ -63,6 +64,7 @@ export abstract class BaseStore<T = any> extends Singleton {
async load() {
const { autoLoad, syncEnabled, ...confOptions } = this.params;
this.storeConfig = new Config({
...confOptions,
projectName: "lens",
@ -90,19 +92,23 @@ export abstract class BaseStore<T = any> extends Singleton {
this.syncDisposers.push(
reaction(() => this.toJSON(), model => this.onModelChange(model), this.params.syncOptions),
);
if (ipcMain) {
const callback = (event: IpcMainEvent, model: T) => {
logger.silly(`[STORE]: SYNC ${this.name} from renderer`, { model });
this.onSync(model);
};
subscribeToBroadcast(this.syncMainChannel, callback);
this.syncDisposers.push(() => unsubscribeFromBroadcast(this.syncMainChannel, callback));
}
if (ipcRenderer) {
const callback = (event: IpcRendererEvent, model: T) => {
logger.silly(`[STORE]: SYNC ${this.name} from main`, { model });
this.onSyncFromMain(model);
};
subscribeToBroadcast(this.syncRendererChannel, callback);
this.syncDisposers.push(() => unsubscribeFromBroadcast(this.syncRendererChannel, callback));
}
@ -127,6 +133,7 @@ export abstract class BaseStore<T = any> extends Singleton {
protected applyWithoutSync(callback: () => void) {
this.disableSync();
runInAction(callback);
if (this.params.syncEnabled) {
this.enableSync();
}

View File

@ -15,6 +15,7 @@ export const clusterKubectlApplyAllHandler = "cluster:kubectl-apply-all";
if (ipcMain) {
handleRequest(clusterActivateHandler, (event, clusterId: ClusterId, force = false) => {
const cluster = clusterStore.getById(clusterId);
if (cluster) {
return cluster.activate(force);
}
@ -22,20 +23,24 @@ if (ipcMain) {
handleRequest(clusterSetFrameIdHandler, (event, clusterId: ClusterId, frameId: number) => {
const cluster = clusterStore.getById(clusterId);
if (cluster) {
clusterFrameMap.set(cluster.id, frameId);
return cluster.pushState();
}
});
handleRequest(clusterRefreshHandler, (event, clusterId: ClusterId) => {
const cluster = clusterStore.getById(clusterId);
if (cluster) return cluster.refresh({ refreshMetadata: true });
});
handleRequest(clusterDisconnectHandler, (event, clusterId: ClusterId) => {
appEventBus.emit({name: "cluster", action: "stop"});
const cluster = clusterStore.getById(clusterId);
if (cluster) {
cluster.disconnect();
clusterFrameMap.delete(cluster.id);
@ -45,8 +50,10 @@ if (ipcMain) {
handleRequest(clusterKubectlApplyAllHandler, (event, clusterId: ClusterId, resources: string[]) => {
appEventBus.emit({name: "cluster", action: "kubectl-apply-all"});
const cluster = clusterStore.getById(clusterId);
if (cluster) {
const applier = new ResourceApplier(cluster);
applier.kubectlApplyAll(resources);
} else {
throw `${clusterId} is not a valid cluster id`;

View File

@ -11,7 +11,7 @@ import { appEventBus } from "./event-bus";
import { dumpConfigYaml } from "./kube-helpers";
import { saveToAppFiles } from "./utils/saveToAppFiles";
import { KubeConfig } from "@kubernetes/client-node";
import { subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc";
import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc";
import _ from "lodash";
import move from "array-move";
import type { WorkspaceId } from "./workspace-store";
@ -40,13 +40,30 @@ export interface ClusterStoreModel {
export type ClusterId = string;
export interface ClusterModel {
/** Unique id for a cluster */
id: ClusterId;
/** Path to cluster kubeconfig */
kubeConfigPath: string;
/** Workspace id */
workspace?: WorkspaceId;
/** User context in kubeconfig */
contextName?: string;
/** Preferences */
preferences?: ClusterPreferences;
/** Metadata */
metadata?: ClusterMetadata;
/**
* If extension sets ownerRef it has to explicitly mark a cluster as enabled during onActive (or when cluster is saved)
*/
ownerRef?: string;
/** List of accessible namespaces */
accessibleNamespaces?: string[];
/** @deprecated */
@ -81,7 +98,9 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
static embedCustomKubeConfig(clusterId: ClusterId, kubeConfig: KubeConfig | string): string {
const filePath = ClusterStore.getCustomKubeConfigPath(clusterId);
const fileContents = typeof kubeConfig == "string" ? kubeConfig : dumpConfigYaml(kubeConfig);
saveToAppFiles(filePath, fileContents, { mode: 0o600 });
return filePath;
}
@ -89,6 +108,8 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
@observable removedClusters = observable.map<ClusterId, Cluster>();
@observable clusters = observable.map<ClusterId, Cluster>();
private static stateRequestChannel = "cluster:states";
private constructor() {
super({
configName: "lens-cluster-store",
@ -102,8 +123,45 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
this.pushStateToViewsAutomatically();
}
async load() {
await super.load();
type clusterStateSync = {
id: string;
state: ClusterState;
};
if (ipcRenderer) {
logger.info("[CLUSTER-STORE] requesting initial state sync");
const clusterStates: clusterStateSync[] = await requestMain(ClusterStore.stateRequestChannel);
clusterStates.forEach((clusterState) => {
const cluster = this.getById(clusterState.id);
if (cluster) {
cluster.setState(clusterState.state);
}
});
} else {
handleRequest(ClusterStore.stateRequestChannel, (): clusterStateSync[] => {
const states: clusterStateSync[] = [];
this.clustersList.forEach((cluster) => {
states.push({
state: cluster.getState(),
id: cluster.id
});
});
return states;
});
}
}
protected pushStateToViewsAutomatically() {
if (!ipcRenderer) {
reaction(() => this.enabledClustersList, () => {
this.pushState();
});
reaction(() => this.connectedClustersList, () => {
this.pushState();
});
@ -156,6 +214,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
@action
setActive(id: ClusterId) {
const clusterId = this.clusters.has(id) ? id : null;
this.activeCluster = clusterId;
workspaceStore.setLastActiveClusterId(clusterId);
}
@ -163,11 +222,13 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
@action
swapIconOrders(workspace: WorkspaceId, from: number, to: number) {
const clusters = this.getByWorkspaceId(workspace);
if (from < 0 || to < 0 || from >= clusters.length || to >= clusters.length || isNaN(from) || isNaN(to)) {
throw new Error(`invalid from<->to arguments`);
}
move.mutate(clusters, from, to);
for (const i in clusters) {
// This resets the iconOrder to the current display order
clusters[i].preferences.iconOrder = +i;
@ -185,12 +246,14 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
getByWorkspaceId(workspaceId: string): Cluster[] {
const clusters = Array.from(this.clusters.values())
.filter(cluster => cluster.workspace === workspaceId);
return _.sortBy(clusters, cluster => cluster.preferences.iconOrder);
}
@action
addClusters(...models: ClusterModel[]): Cluster[] {
const clusters: Cluster[] = [];
models.forEach(model => {
clusters.push(this.addCluster(model));
});
@ -202,10 +265,16 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
addCluster(model: ClusterModel | Cluster): Cluster {
appEventBus.emit({ name: "cluster", action: "add" });
let cluster = model as Cluster;
if (!(model instanceof Cluster)) {
cluster = new Cluster(model);
}
if (!cluster.isManaged) {
cluster.enabled = true;
}
this.clusters.set(model.id, cluster);
return cluster;
}
@ -217,11 +286,14 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
async removeById(clusterId: ClusterId) {
appEventBus.emit({ name: "cluster", action: "remove" });
const cluster = this.getById(clusterId);
if (cluster) {
this.clusters.delete(clusterId);
if (this.activeCluster === clusterId) {
this.setActive(null);
}
// remove only custom kubeconfigs (pasted as text)
if (cluster.kubeConfigPath == ClusterStore.getCustomKubeConfigPath(clusterId)) {
unlink(cluster.kubeConfigPath).catch(() => null);
@ -245,10 +317,12 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
// update new clusters
for (const clusterModel of clusters) {
let cluster = currentClusters.get(clusterModel.id);
if (cluster) {
cluster.updateModel(clusterModel);
} else {
cluster = new Cluster(clusterModel);
if (!cluster.isManaged) {
cluster.enabled = true;
}
@ -282,6 +356,7 @@ export const clusterStore = ClusterStore.getInstance<ClusterStore>();
export function getClusterIdFromHost(hostname: string): ClusterId {
const subDomains = hostname.split(":")[0].split(".");
return subDomains.slice(-2)[0]; // e.g host == "%clusterId.localhost:45345"
}

View File

@ -2,6 +2,7 @@ export class ExecValidationNotFoundError extends Error {
constructor(execPath: string, isAbsolute: boolean) {
super(`User Exec command "${execPath}" not found on host.`);
let message = `User Exec command "${execPath}" not found on host.`;
if (!isAbsolute) {
message += ` Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig`;
}

View File

@ -13,6 +13,7 @@ export class EventEmitter<D extends [...any[]]> {
addListener(callback: Callback<D>, options: Options = {}) {
if (options.prepend) {
const listeners = [...this.listeners];
listeners.unshift([callback, options]);
this.listeners = new Map(listeners);
}
@ -33,7 +34,9 @@ export class EventEmitter<D extends [...any[]]> {
[...this.listeners].every(([callback, options]) => {
if (options.once) this.removeListener(callback);
const result = callback(...data);
if (result === false) return; // break cycle
return true;
});
}

View File

@ -16,18 +16,22 @@ export async function requestMain(channel: string, ...args: any[]) {
async function getSubFrames(): Promise<number[]> {
const subFrames: number[] = [];
clusterFrameMap.forEach(frameId => {
subFrames.push(frameId);
});
return subFrames;
}
export function broadcastMessage(channel: string, ...args: any[]) {
const views = (webContents || remote?.webContents)?.getAllWebContents();
if (!views) return;
views.forEach(webContent => {
const type = webContent.getType();
logger.silly(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args });
webContent.send(channel, ...args);
getSubFrames().then((frames) => {
@ -36,6 +40,7 @@ export function broadcastMessage(channel: string, ...args: any[]) {
});
}).catch((e) => e);
});
if (ipcRenderer) {
ipcRenderer.send(channel, ...args);
} else {

View File

@ -13,6 +13,7 @@ function resolveTilde(filePath: string) {
if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) {
return filePath.replace("~", os.homedir());
}
return filePath;
}
@ -40,12 +41,15 @@ export function validateConfig(config: KubeConfig | string): KubeConfig {
config = loadConfig(config);
}
logger.debug(`validating kube config: ${JSON.stringify(config)}`);
if (!config.users || config.users.length == 0) {
throw new Error("No users provided in config");
}
if (!config.clusters || config.clusters.length == 0) {
throw new Error("No clusters provided in config");
}
if (!config.contexts || config.contexts.length == 0) {
throw new Error("No contexts provided in config");
}
@ -58,11 +62,13 @@ export function validateConfig(config: KubeConfig | string): KubeConfig {
*/
export function splitConfig(kubeConfig: KubeConfig): KubeConfig[] {
const configs: KubeConfig[] = [];
if (!kubeConfig.contexts) {
return configs;
}
kubeConfig.contexts.forEach(ctx => {
const kc = new KubeConfig();
kc.clusters = [kubeConfig.getCluster(ctx.cluster)].filter(n => n);
kc.users = [kubeConfig.getUser(ctx.user)].filter(n => n);
kc.contexts = [kubeConfig.getContextObject(ctx.name)].filter(n => n);
@ -70,6 +76,7 @@ export function splitConfig(kubeConfig: KubeConfig): KubeConfig[] {
configs.push(kc);
});
return configs;
}
@ -153,11 +160,13 @@ export function validateKubeConfig (config: KubeConfig) {
logger.debug(`validateKubeConfig: validating kubeconfig - ${JSON.stringify(config)}`);
// Validate the User Object
const user = config.getCurrentUser();
const user = config.getCurrentUser();
if (user.exec) {
const execCommand = user.exec["command"];
// check if the command is absolute or not
const isAbsolute = path.isAbsolute(execCommand);
// validate the exec struct in the user object, start with the command field
logger.debug(`validateKubeConfig: validating user exec command - ${JSON.stringify(execCommand)}`);

View File

@ -6,6 +6,7 @@ import { PrometheusProviderRegistry } from "../main/prometheus/provider-registry
[PrometheusLens, PrometheusHelm, PrometheusOperator, PrometheusStacklight].forEach(providerClass => {
const provider = new providerClass();
PrometheusProviderRegistry.registerProvider(provider.id, provider);
});

View File

@ -42,10 +42,12 @@ export function isAllowedResource(resources: KubeResource | KubeResource[]) {
resources = [resources];
}
const { allowedResources = [] } = getHostedCluster() || {};
for (const resource of resources) {
if (!allowedResources.includes(resource)) {
return false;
}
}
return true;
}

View File

@ -7,6 +7,7 @@ export function registerFileProtocol(name: string, basePath: string) {
protocol.registerFileProtocol(name, (request, callback) => {
const filePath = request.url.replace(`${name}://`, "");
const absPath = path.resolve(basePath, filePath);
callback({ path: absPath });
});
}

View File

@ -7,6 +7,7 @@ import { userStore } from "./user-store";
function getDefaultRequestOpts(): Partial<request.Options> {
const { httpsProxy, allowUntrustedCAs } = userStore.preferences;
return {
proxy: httpsProxy || undefined,
rejectUnauthorized: !allowUntrustedCAs,

View File

@ -14,8 +14,10 @@ export class SearchStore {
@action
onSearch(text: string[], query = this.searchQuery) {
this.searchQuery = query;
if (!query) {
this.reset();
return;
}
this.occurrences = this.findOccurences(text, query);
@ -36,11 +38,14 @@ export class SearchStore {
findOccurences(text: string[], query: string) {
if (!text) return [];
const occurences: number[] = [];
text.forEach((line, index) => {
const regex = new RegExp(this.escapeRegex(query), "gi");
const matches = [...line.matchAll(regex)];
matches.forEach(() => occurences.push(index));
});
return occurences;
}
@ -51,9 +56,11 @@ export class SearchStore {
*/
getNextOverlay(loopOver = false) {
const next = this.activeOverlayIndex + 1;
if (next > this.occurrences.length - 1) {
return loopOver ? 0 : this.activeOverlayIndex;
}
return next;
}
@ -64,9 +71,11 @@ export class SearchStore {
*/
getPrevOverlay(loopOver = false) {
const prev = this.activeOverlayIndex - 1;
if (prev < 0) {
return loopOver ? this.occurrences.length - 1 : this.activeOverlayIndex;
}
return prev;
}
@ -104,6 +113,7 @@ export class SearchStore {
@autobind()
isActiveOverlay(line: number, occurence: number) {
const firstLineIndex = this.occurrences.findIndex(item => item === line);
return firstLineIndex + occurence === this.activeOverlayIndex;
}

View File

@ -6,9 +6,11 @@ import logger from "../main/logger";
if (isMac) {
for (const crt of macca.all()) {
const attributes = crt.issuer?.attributes?.map((a: any) => `${a.name}=${a.value}`);
logger.debug(`Using host CA: ${attributes.join(",")}`);
}
}
if (isWindows) {
winca.inject("+"); // see: https://github.com/ukoloff/win-ca#caveats
}

View File

@ -102,6 +102,7 @@ export class UserStore extends BaseStore<UserStoreModel> {
protected refreshNewContexts = async () => {
try {
const kubeConfig = await readFile(this.kubeConfigPath, "utf8");
if (kubeConfig) {
this.newContexts.clear();
loadConfig(kubeConfig).getContexts()
@ -118,6 +119,7 @@ export class UserStore extends BaseStore<UserStoreModel> {
@action
markNewContextsAsSeen() {
const { seenContexts, newContexts } = this;
this.seenContexts.replace([...seenContexts, ...newContexts]);
this.newContexts.clear();
}
@ -133,9 +135,11 @@ export class UserStore extends BaseStore<UserStoreModel> {
@action
protected async fromStore(data: Partial<UserStoreModel> = {}) {
const { lastSeenAppVersion, seenContexts = [], preferences, kubeConfigPath } = data;
if (lastSeenAppVersion) {
this.lastSeenAppVersion = lastSeenAppVersion;
}
if (kubeConfigPath) {
this.kubeConfigPath = kubeConfigPath;
}
@ -150,6 +154,7 @@ export class UserStore extends BaseStore<UserStoreModel> {
seenContexts: Array.from(this.seenContexts),
preferences: this.preferences,
};
return toJS(model, {
recurseEverything: true,
});

View File

@ -12,7 +12,6 @@ export function autobind() {
function bindClass<T extends Constructor>(constructor: T) {
const proto = constructor.prototype;
const descriptors = Object.getOwnPropertyDescriptors(proto);
const skipMethod = (methodName: string) => {
return methodName === "constructor"
|| typeof descriptors[methodName].value !== "function";
@ -21,6 +20,7 @@ function bindClass<T extends Constructor>(constructor: T) {
Object.keys(descriptors).forEach(prop => {
if (skipMethod(prop)) return;
const boundDescriptor = bindMethod(proto, prop, descriptors[prop]);
Object.defineProperty(proto, prop, boundDescriptor);
});
}
@ -38,6 +38,7 @@ function bindMethod(target: object, prop?: string, descriptor?: PropertyDescript
get() {
if (this === target) return func; // direct access from prototype
if (!boundFunc.has(this)) boundFunc.set(this, func.bind(this));
return boundFunc.get(this);
}
});

View File

@ -7,8 +7,10 @@ export interface IURLParams<P extends object = {}, Q extends object = {}> {
export function buildURL<P extends object = {}, Q extends object = {}>(path: string | any) {
const pathBuilder = compile(String(path));
return function ({ params, query }: IURLParams<P, Q> = {}) {
const queryParams = query ? new URLSearchParams(Object.entries(query)).toString() : "";
return pathBuilder(params) + (queryParams ? `?${queryParams}` : "");
};
}

View File

@ -8,7 +8,9 @@ export function toCamelCase(obj: Record<string, any>): any {
else if (isPlainObject(obj)) {
return Object.keys(obj).reduce((result, key) => {
const value = obj[key];
result[camelCase(key)] = typeof value === "object" ? toCamelCase(value) : value;
return result;
}, {} as any);
}

View File

@ -2,6 +2,7 @@
export function debouncePromise<T, F extends any[]>(func: (...args: F) => T | Promise<T>, timeout = 0): (...args: F) => Promise<T> {
let timer: NodeJS.Timeout;
return (...params: any[]) => new Promise(resolve => {
clearTimeout(timer);
timer = global.setTimeout(() => resolve(func.apply(this, params)), timeout);

View File

@ -26,6 +26,7 @@ export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions)
resolve(Buffer.concat(fileChunks));
});
});
return {
url,
promise,

View File

@ -2,5 +2,6 @@
export function getRandId({ prefix = "", suffix = "", sep = "_" } = {}) {
const randId = () => Math.random().toString(16).substr(2);
return [prefix, randId(), suffix].filter(s => s).join(sep);
}

View File

@ -6,7 +6,9 @@ import { WriteFileOptions } from "fs";
export function saveToAppFiles(filePath: string, contents: any, options?: WriteFileOptions): string {
const absPath = path.resolve((app || remote.app).getPath("userData"), filePath);
ensureDirSync(path.dirname(absPath));
writeFileSync(absPath, contents, options);
return absPath;
}

View File

@ -16,6 +16,7 @@ class Singleton {
if (!Singleton.instances.has(this)) {
Singleton.instances.set(this, Reflect.construct(this, args));
}
return Singleton.instances.get(this) as T;
}

View File

@ -12,8 +12,10 @@
*/
export function splitArray<T>(array: T[], element: T): [T[], T[], boolean] {
const index = array.indexOf(element);
if (index < 0) {
return [array, [], false];
}
return [array.slice(0, index), array.slice(index + 1, array.length), true];
}

View File

@ -15,7 +15,7 @@ export function readFileFromTar<R = Buffer>({ tarPath, filePath, parseJson }: Re
await tar.list({
file: tarPath,
filter: path => path === filePath,
filter: entryPath => path.normalize(entryPath) === filePath,
onentry(entry: FileStat) {
entry.on("data", chunk => {
fileChunks.push(chunk);
@ -26,6 +26,7 @@ export function readFileFromTar<R = Buffer>({ tarPath, filePath, parseJson }: Re
entry.once("end", () => {
const data = Buffer.concat(fileChunks);
const result = parseJson ? JSON.parse(data.toString("utf8")) : data;
resolve(result);
});
},
@ -39,10 +40,14 @@ export function readFileFromTar<R = Buffer>({ tarPath, filePath, parseJson }: Re
export async function listTarEntries(filePath: string): Promise<string[]> {
const entries: string[] = [];
await tar.list({
file: filePath,
onentry: (entry: FileStat) => entries.push(entry.path as any as string),
onentry: (entry: FileStat) => {
entries.push(path.normalize(entry.path as any as string));
},
});
return entries;
}

View File

@ -30,6 +30,7 @@ defineGlobal("__static", {
if (isDevelopment) {
return path.resolve(contextDir, "static");
}
return path.resolve(process.resourcesPath, "static");
}
});

View File

@ -3,7 +3,7 @@ import { action, computed, observable, toJS, reaction } from "mobx";
import { BaseStore } from "./base-store";
import { clusterStore } from "./cluster-store";
import { appEventBus } from "./event-bus";
import { broadcastMessage } from "../common/ipc";
import { broadcastMessage, handleRequest, requestMain } from "../common/ipc";
import logger from "../main/logger";
import type { ClusterId } from "./cluster-store";
@ -33,32 +33,44 @@ export interface WorkspaceState {
*/
export class Workspace implements WorkspaceModel, WorkspaceState {
/**
* Unique id
* Unique id for workspace
*
* @observable
*/
@observable id: WorkspaceId;
/**
* Workspace name
*
* @observable
*/
@observable name: string;
/**
* Description
* Workspace description
*
* @observable
*/
@observable description?: string;
/**
* Owner reference
* Workspace owner reference
*
* If extension sets ownerRef then it needs to explicitly mark workspace as enabled onActivate (or when workspace is saved)
*
* If an extension sets this then extension also needs explicitly to set workspace as enabled
* @observable
*/
@observable ownerRef?: string;
/**
* Is workspace enabled (disabled workspaces are currently hidden)
* Is workspace enabled
*
* Workspaces that don't have ownerRef will be enabled by default. Workspaces with ownerRef need to explicitly enable a workspace.
*
* @observable
*/
@observable enabled: boolean;
/**
* Last active cluster id
*
* @observable
*/
@observable lastActiveClusterId?: ClusterId;
constructor(data: WorkspaceModel) {
@ -83,9 +95,9 @@ export class Workspace implements WorkspaceModel, WorkspaceState {
*
*/
getState(): WorkspaceState {
return {
return toJS({
enabled: this.enabled
};
});
}
/**
@ -120,16 +132,45 @@ export class Workspace implements WorkspaceModel, WorkspaceState {
export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
static readonly defaultId: WorkspaceId = "default";
private static stateRequestChannel = "workspace:states";
private constructor() {
super({
configName: "lens-workspace-store",
});
}
if (!ipcRenderer) {
setInterval(() => {
this.pushState();
}, 5000);
async load() {
await super.load();
type workspaceStateSync = {
id: string;
state: WorkspaceState;
};
if (ipcRenderer) {
logger.info("[WORKSPACE-STORE] requesting initial state sync");
const workspaceStates: workspaceStateSync[] = await requestMain(WorkspaceStore.stateRequestChannel);
workspaceStates.forEach((workspaceState) => {
const workspace = this.getById(workspaceState.id);
if (workspace) {
workspace.setState(workspaceState.state);
}
});
} else {
handleRequest(WorkspaceStore.stateRequestChannel, (): workspaceStateSync[] => {
const states: workspaceStateSync[] = [];
this.workspacesList.forEach((workspace) => {
states.push({
state: workspace.getState(),
id: workspace.id
});
});
return states;
});
}
}
@ -187,6 +228,7 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
@action
setActive(id = WorkspaceStore.defaultId) {
if (id === this.currentWorkspaceId) return;
if (!this.getById(id)) {
throw new Error(`workspace ${id} doesn't exist`);
}
@ -196,11 +238,18 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
@action
addWorkspace(workspace: Workspace) {
const { id, name } = workspace;
if (!name.trim() || this.getByName(name.trim())) {
return;
}
this.workspaces.set(id, workspace);
if (!workspace.isManaged) {
workspace.enabled = true;
}
appEventBus.emit({name: "workspace", action: "add"});
return workspace;
}
@ -218,10 +267,13 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
@action
removeWorkspaceById(id: WorkspaceId) {
const workspace = this.getById(id);
if (!workspace) return;
if (this.isDefault(id)) {
throw new Error("Cannot remove default workspace");
}
if (this.currentWorkspaceId === id) {
this.currentWorkspaceId = WorkspaceStore.defaultId; // reset to default
}
@ -240,10 +292,12 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
if (currentWorkspace) {
this.currentWorkspaceId = currentWorkspace;
}
if (workspaces.length) {
this.workspaces.clear();
workspaces.forEach(ws => {
const workspace = new Workspace(ws);
if (!workspace.isManaged) {
workspace.enabled = true;
}

View File

@ -1,15 +1,24 @@
import { ExtensionLoader } from "../extension-loader";
import { ipcRenderer } from "electron";
import { extensionsStore } from "../extensions-store";
const manifestPath = "manifest/path";
const manifestPath2 = "manifest/path2";
const manifestPath3 = "manifest/path3";
jest.mock("../extensions-store", () => ({
extensionsStore: {
whenLoaded: Promise.resolve(true),
mergeState: jest.fn()
}
}));
jest.mock(
"electron",
() => ({
ipcRenderer: {
invoke: jest.fn(async (channel: string) => {
if (channel === "extensions:loaded") {
if (channel === "extensions:main") {
return [
[
manifestPath,
@ -44,7 +53,7 @@ jest.mock(
}),
on: jest.fn(
(channel: string, listener: (event: any, ...args: any[]) => void) => {
if (channel === "extensions:loaded") {
if (channel === "extensions:main") {
// First initialize with extensions 1 and 2
// and then broadcast event to remove extensioin 2 and add extension number 3
setTimeout(() => {
@ -129,4 +138,29 @@ describe("ExtensionLoader", () => {
done();
}, 10);
});
it("updates ExtensionsStore after isEnabled is changed", async () => {
(extensionsStore.mergeState as any).mockClear();
// Disable sending events in this test
(ipcRenderer.on as any).mockImplementation();
const extensionLoader = new ExtensionLoader();
await extensionLoader.init();
expect(extensionsStore.mergeState).not.toHaveBeenCalled();
Array.from(extensionLoader.userExtensions.values())[0].isEnabled = false;
expect(extensionsStore.mergeState).toHaveBeenCalledWith({
"manifest/path": {
enabled: false,
name: "TestExtension"
},
"manifest/path2": {
enabled: true,
name: "TestExtension2"
}});
});
});

View File

@ -108,12 +108,15 @@ export abstract class ClusterFeature {
*/
protected renderTemplates(folderPath: string): string[] {
const resources: string[] = [];
logger.info(`[FEATURE]: render templates from ${folderPath}`);
fs.readdirSync(folderPath).forEach(filename => {
const file = path.join(folderPath, filename);
const raw = fs.readFileSync(file);
if (filename.endsWith(".hb")) {
const template = hb.compile(raw.toString());
resources.push(template(this.templateContext));
} else {
resources.push(raw.toString());

View File

@ -3,6 +3,7 @@ import { extensionsStore } from "../extensions-store";
export const version = getAppVersion();
export { isSnap, isWindows, isMac, isLinux, appName, slackUrl, issuesTrackerUrl } from "../../common/vars";
export function getEnabledExtensions(): string[] {
return extensionsStore.enabledExtensions;
}

View File

@ -25,6 +25,7 @@ export interface InstalledExtension {
}
const logModule = "[EXTENSION-DISCOVERY]";
export const manifestFilename = "package.json";
/**
@ -113,8 +114,8 @@ export class ExtensionDiscovery {
// chokidar works better than fs.watch
chokidar.watch(this.localFolderPath, {
// Dont watch recursively into subdirectories
depth: 0,
// For adding and removing symlinks to work, the depth has to be 1.
depth: 1,
// Try to wait until the file has been completely copied.
// The OS might emit an event for added file even it's not completely written to the filesysten.
awaitWriteFinish: {
@ -123,7 +124,7 @@ export class ExtensionDiscovery {
stabilityThreshold: 300
}
})
// Extension add is detected by watching "<extensionDir>package.json" add
// Extension add is detected by watching "<extensionDir>/package.json" add
.on("add", this.handleWatchFileAdd)
// Extension remove is detected by watching <extensionDir>" unlink
.on("unlinkDir", this.handleWatchUnlinkDir);
@ -133,7 +134,6 @@ export class ExtensionDiscovery {
if (path.basename(filePath) === manifestFilename) {
try {
const absPath = path.dirname(filePath);
// this.loadExtensionFromPath updates this.packagesJson
const extension = await this.loadExtensionFromPath(absPath);
@ -189,7 +189,7 @@ export class ExtensionDiscovery {
*/
async uninstallExtension(absolutePath: string) {
logger.info(`${logModule} Uninstalling ${absolutePath}`);
const exists = await fs.pathExists(absolutePath);
if (!exists) {
@ -251,6 +251,7 @@ export class ExtensionDiscovery {
manifestJson = __non_webpack_require__(manifestPath);
const installedManifestPath = path.join(this.nodeModulesPath, manifestJson.name, "package.json");
this.packagesJson.dependencies[manifestJson.name] = path.dirname(manifestPath);
const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath);
@ -272,6 +273,7 @@ export class ExtensionDiscovery {
async loadExtensions(): Promise<Map<LensExtensionId, InstalledExtension>> {
const bundledExtensions = await this.loadBundledExtensions();
const localExtensions = await this.loadFromFolder(this.localFolderPath);
await this.installPackages();
const extensions = bundledExtensions.concat(localExtensions);
@ -333,12 +335,14 @@ export class ExtensionDiscovery {
}
const extension = await this.loadExtensionFromPath(absPath);
if (extension) {
extensions.push(extension);
}
}
logger.debug(`${logModule}: ${extensions.length} extensions loaded`, { folderPath, extensions });
return extensions;
}

View File

@ -37,6 +37,7 @@ export class ExtensionInstaller {
cwd: extensionPackagesRoot(),
silent: true
});
child.on("close", () => {
resolve();
});

View File

@ -1,5 +1,6 @@
import { app, ipcRenderer, remote } from "electron";
import { EventEmitter } from "events";
import { isEqual } from "lodash";
import { action, computed, observable, reaction, toJS, when } from "mobx";
import path from "path";
import { getHostedCluster } from "../common/cluster-store";
@ -25,7 +26,12 @@ const logModule = "[EXTENSIONS-LOADER]";
export class ExtensionLoader {
protected extensions = observable.map<LensExtensionId, InstalledExtension>();
protected instances = observable.map<LensExtensionId, LensExtension>();
protected readonly requestExtensionsChannel = "extensions:loaded";
// IPC channel to broadcast changes to extensions from main
protected static readonly extensionsMainChannel = "extensions:main";
// IPC channel to broadcast changes to extensions from renderer
protected static readonly extensionsRendererChannel = "extensions:renderer";
// emits event "remove" of type LensExtension when the extension is removed
private events = new EventEmitter();
@ -45,6 +51,17 @@ export class ExtensionLoader {
return extensions;
}
// Transform userExtensions to a state object for storing into ExtensionsStore
@computed get storeState() {
return Object.fromEntries(
Array.from(this.userExtensions)
.map(([extId, extension]) => [extId, {
enabled: extension.isEnabled,
name: extension.manifest.name,
}])
);
}
@action
async init() {
if (ipcRenderer) {
@ -53,7 +70,12 @@ export class ExtensionLoader {
await this.initMain();
}
extensionsStore.manageState(this);
await Promise.all([this.whenLoaded, extensionsStore.whenLoaded]);
// save state on change `extension.isEnabled`
reaction(() => this.storeState, extensionsState => {
extensionsStore.mergeState(extensionsState);
});
}
initExtensions(extensions?: Map<LensExtensionId, InstalledExtension>) {
@ -95,28 +117,27 @@ export class ExtensionLoader {
this.loadOnMain();
this.broadcastExtensions();
reaction(() => this.extensions.toJS(), () => {
reaction(() => this.toJSON(), () => {
this.broadcastExtensions();
});
handleRequest(this.requestExtensionsChannel, () => {
handleRequest(ExtensionLoader.extensionsMainChannel, () => {
return Array.from(this.toJSON());
});
subscribeToBroadcast(ExtensionLoader.extensionsRendererChannel, (_event, extensions: [LensExtensionId, InstalledExtension][]) => {
this.syncExtensions(extensions);
});
}
protected async initRenderer() {
const extensionListHandler = (extensions: [LensExtensionId, InstalledExtension][]) => {
this.isLoaded = true;
this.syncExtensions(extensions);
const receivedExtensionIds = extensions.map(([lensExtensionId]) => lensExtensionId);
// Add new extensions
extensions.forEach(([extId, ext]) => {
if (!this.extensions.has(extId)) {
this.extensions.set(extId, ext);
}
});
// Remove deleted extensions
// Remove deleted extensions in renderer side only
this.extensions.forEach((_, lensExtensionId) => {
if (!receivedExtensionIds.includes(lensExtensionId)) {
this.removeExtension(lensExtensionId);
@ -124,14 +145,26 @@ export class ExtensionLoader {
});
};
requestMain(this.requestExtensionsChannel).then(extensionListHandler);
subscribeToBroadcast(this.requestExtensionsChannel, (event, extensions: [LensExtensionId, InstalledExtension][]) => {
reaction(() => this.toJSON(), () => {
this.broadcastExtensions(false);
});
requestMain(ExtensionLoader.extensionsMainChannel).then(extensionListHandler);
subscribeToBroadcast(ExtensionLoader.extensionsMainChannel, (_event, extensions: [LensExtensionId, InstalledExtension][]) => {
extensionListHandler(extensions);
});
}
syncExtensions(extensions: [LensExtensionId, InstalledExtension][]) {
extensions.forEach(([lensExtensionId, extension]) => {
if (!isEqual(this.extensions.get(lensExtensionId), extension)) {
this.extensions.set(lensExtensionId, extension);
}
});
}
loadOnMain() {
logger.info(`${logModule}: load on main`);
logger.debug(`${logModule}: load on main`);
this.autoInitExtensions(async (extension: LensMainExtension) => {
// Each .add returns a function to remove the item
const removeItems = [
@ -151,7 +184,7 @@ export class ExtensionLoader {
}
loadOnClusterManagerRenderer() {
logger.info(`${logModule}: load on main renderer (cluster manager)`);
logger.debug(`${logModule}: load on main renderer (cluster manager)`);
this.autoInitExtensions(async (extension: LensRendererExtension) => {
const removeItems = [
registries.globalPageRegistry.add(extension.globalPages, extension),
@ -174,8 +207,9 @@ export class ExtensionLoader {
}
loadOnClusterRenderer() {
logger.info(`${logModule}: load on cluster renderer (dashboard)`);
logger.debug(`${logModule}: load on cluster renderer (dashboard)`);
const cluster = getHostedCluster();
this.autoInitExtensions(async (extension: LensRendererExtension) => {
if (await extension.isEnabledForCluster(cluster) === false) {
return [];
@ -203,24 +237,26 @@ export class ExtensionLoader {
protected autoInitExtensions(register: (ext: LensExtension) => Promise<Function[]>) {
return reaction(() => this.toJSON(), installedExtensions => {
for (const [extId, ext] of installedExtensions) {
for (const [extId, extension] of installedExtensions) {
const alreadyInit = this.instances.has(extId);
if (ext.isEnabled && !alreadyInit) {
if (extension.isEnabled && !alreadyInit) {
try {
const LensExtensionClass = this.requireExtension(ext);
const LensExtensionClass = this.requireExtension(extension);
if (!LensExtensionClass) {
continue;
}
const instance = new LensExtensionClass(ext);
const instance = new LensExtensionClass(extension);
instance.whenEnabled(() => register(instance));
instance.enable();
this.instances.set(extId, instance);
} catch (err) {
logger.error(`${logModule}: activation extension error`, { ext, err });
logger.error(`${logModule}: activation extension error`, { ext: extension, err });
}
} else if (!ext.isEnabled && alreadyInit) {
} else if (!extension.isEnabled && alreadyInit) {
this.removeInstance(extId);
}
}
@ -231,12 +267,14 @@ export class ExtensionLoader {
protected requireExtension(extension: InstalledExtension): LensExtensionConstructor {
let extEntrypoint = "";
try {
if (ipcRenderer && extension.manifest.renderer) {
extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.renderer));
} else if (!ipcRenderer && extension.manifest.main) {
extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.main));
}
if (extEntrypoint !== "") {
return __non_webpack_require__(extEntrypoint).default;
}
@ -257,8 +295,8 @@ export class ExtensionLoader {
});
}
broadcastExtensions() {
broadcastMessage(this.requestExtensionsChannel, Array.from(this.toJSON()));
broadcastExtensions(main = true) {
broadcastMessage(main ? ExtensionLoader.extensionsMainChannel : ExtensionLoader.extensionsRendererChannel, Array.from(this.toJSON()));
}
}

View File

@ -7,11 +7,13 @@ export abstract class ExtensionStore<T> extends BaseStore<T> {
async loadExtension(extension: LensExtension) {
this.extension = extension;
return super.load();
}
async load() {
if (!this.extension) { return; }
return super.load();
}

View File

@ -1,7 +1,6 @@
import type { LensExtensionId } from "./lens-extension";
import type { ExtensionLoader } from "./extension-loader";
import { BaseStore } from "../common/base-store";
import { action, computed, observable, reaction, toJS } from "mobx";
import { action, computed, observable, toJS } from "mobx";
export interface LensExtensionsStoreModel {
extensions: Record<LensExtensionId, LensExtensionState>;
@ -28,40 +27,17 @@ export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
protected state = observable.map<LensExtensionId, LensExtensionState>();
protected getState(extensionLoader: ExtensionLoader) {
const state: Record<LensExtensionId, LensExtensionState> = {};
return Array.from(extensionLoader.userExtensions).reduce((state, [extId, ext]) => {
state[extId] = {
enabled: ext.isEnabled,
name: ext.manifest.name,
};
return state;
}, state);
}
async manageState(extensionLoader: ExtensionLoader) {
await extensionLoader.whenLoaded;
await this.whenLoaded;
// apply state on changes from store
reaction(() => this.state.toJS(), extensionsState => {
extensionsState.forEach((state, extId) => {
const ext = extensionLoader.getExtension(extId);
if (ext && !ext.isBundled) {
ext.isEnabled = state.enabled;
}
});
});
// save state on change `extension.isEnabled`
reaction(() => this.getState(extensionLoader), extensionsState => {
this.state.merge(extensionsState);
});
}
isEnabled(extId: LensExtensionId) {
const state = this.state.get(extId);
return state && state.enabled; // by default false
// By default false, so that copied extensions are disabled by default.
// If user installs the extension from the UI, the Extensions component will specifically enable it.
return Boolean(state?.enabled);
}
@action
mergeState(extensionsState: Record<LensExtensionId, LensExtensionState>) {
this.state.merge(extensionsState);
}
@action

View File

@ -86,6 +86,7 @@ export class LensExtension {
const cancelReaction = reaction(() => this.isEnabled, async (isEnabled) => {
if (isEnabled) {
const handlerDisposers = await handlers();
disposers.push(...handlerDisposers);
} else {
unregisterHandlers();
@ -93,6 +94,7 @@ export class LensExtension {
}, {
fireImmediately: true
});
return () => {
unregisterHandlers();
cancelReaction();

View File

@ -13,6 +13,7 @@ export class LensMainExtension extends LensExtension {
pageId,
params: params ?? {}, // compile to url with params
});
await windowManager.navigate(pageUrl, frameId);
}
}

View File

@ -23,6 +23,7 @@ export class LensRendererExtension extends LensExtension {
pageId,
params: params ?? {}, // compile to url with params
});
navigate(pageUrl);
}

View File

@ -73,6 +73,7 @@ describe("globalPageRegistry", () => {
describe("getByPageMenuTarget", () => {
it("matching to first registered page without id", () => {
const page = globalPageRegistry.getByPageMenuTarget({ extensionId: ext.name });
expect(page.id).toEqual(undefined);
expect(page.extensionId).toEqual(ext.name);
expect(page.routePath).toEqual(getExtensionPageUrl({ extensionId: ext.name }));
@ -83,6 +84,7 @@ describe("globalPageRegistry", () => {
pageId: "test-page",
extensionId: ext.name
});
expect(page.id).toEqual("test-page");
});
@ -91,6 +93,7 @@ describe("globalPageRegistry", () => {
pageId: "wrong-page",
extensionId: ext.name
});
expect(page).toBeNull();
});
});

View File

@ -14,7 +14,9 @@ export class BaseRegistry<T> {
@action
add(items: T | T[]) {
const itemArray = rectify(items);
this.items.push(...itemArray);
return () => this.remove(...itemArray);
}

View File

@ -20,8 +20,10 @@ export class KubeObjectDetailRegistry extends BaseRegistry<KubeObjectDetailRegis
if (item.priority === null) {
item.priority = 50;
}
return item;
});
return items.sort((a, b) => b.priority - a.priority);
}
}

View File

@ -35,8 +35,10 @@ export class GlobalPageMenuRegistry extends BaseRegistry<PageMenuRegistration> {
extensionId: ext.name,
...(menuItem.target || {}),
};
return menuItem;
});
return super.add(normalizedItems);
}
}
@ -49,8 +51,10 @@ export class ClusterPageMenuRegistry extends BaseRegistry<ClusterPageMenuRegistr
extensionId: ext.name,
...(menuItem.target || {}),
};
return menuItem;
});
return super.add(normalizedItems);
}

View File

@ -44,10 +44,12 @@ export function getExtensionPageUrl<P extends object>({ extensionId, pageId = ""
const extensionBaseUrl = compile(`/extension/:name`)({
name: sanitizeExtensionName(extensionId), // compile only with extension-id first and define base path
});
const extPageRoutePath = path.join(extensionBaseUrl, pageId); // page-id might contain route :param-s, so don't compile yet
const extPageRoutePath = path.posix.join(extensionBaseUrl, pageId);
if (params) {
return compile(extPageRoutePath)(params); // might throw error when required params not passed
}
return extPageRoutePath;
}
@ -56,6 +58,7 @@ export class PageRegistry extends BaseRegistry<RegisteredPage> {
add(items: PageRegistration | PageRegistration[], ext: LensExtension) {
const itemArray = rectify(items);
let registeredPages: RegisteredPage[] = [];
try {
registeredPages = itemArray.map(page => ({
...page,
@ -69,6 +72,7 @@ export class PageRegistry extends BaseRegistry<RegisteredPage> {
error: String(err),
});
}
return super.add(registeredPages);
}
@ -78,8 +82,10 @@ export class PageRegistry extends BaseRegistry<RegisteredPage> {
getByPageMenuTarget(target: PageMenuTarget = {}): RegisteredPage | null {
const targetUrl = getExtensionPageUrl(target);
return this.getItems().find(({ id: pageId, extensionId }) => {
const pageUrl = getExtensionPageUrl({ extensionId, pageId, params: target.params }); // compiled with provided params
return targetUrl === pageUrl;
}) || null;
}

View File

@ -42,6 +42,7 @@ export class ClusterStore extends Singleton {
if (!this.activeClusterId) {
return null;
}
return this.getById(this.activeClusterId);
}

View File

@ -75,6 +75,7 @@ describe("create clusters", () => {
preferences: {},
})
};
mockFs(mockOpts);
jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValue(Promise.resolve(true));
c = new Cluster({
@ -112,6 +113,7 @@ describe("create clusters", () => {
it("activating cluster should try to connect to cluster and do a refresh", async () => {
const port = await getFreePort();
jest.spyOn(ContextHandler.prototype, "ensureServer");
const mockListNSs = jest.fn();
@ -122,17 +124,13 @@ describe("create clusters", () => {
};
}
};
jest.spyOn(Cluster.prototype, "isClusterAdmin").mockReturnValue(Promise.resolve(true));
jest.spyOn(Cluster.prototype, "canI")
.mockImplementationOnce((attr: V1ResourceAttributes): Promise<boolean> => {
expect(attr.namespace).toBe("default");
expect(attr.resource).toBe("pods");
expect(attr.verb).toBe("list");
return Promise.resolve(true);
})
.mockImplementation((attr: V1ResourceAttributes): Promise<boolean> => {
expect(attr.namespace).toBe("default");
expect(attr.verb).toBe("list");
return Promise.resolve(true);
});
jest.spyOn(Cluster.prototype, "getProxyKubeconfig").mockReturnValue(mockKC as any);
@ -148,6 +146,7 @@ describe("create clusters", () => {
mockedRequest.mockImplementationOnce(((uri: any) => {
expect(uri).toBe(`http://localhost:${port}/api-kube/version`);
return Promise.resolve({ gitVersion: "1.2.3" });
}) as any);
@ -165,6 +164,7 @@ describe("create clusters", () => {
kubeConfigPath: "minikube-config.yml",
workspace: workspaceStore.currentWorkspaceId
});
await c.init(port);
await c.activate();

View File

@ -49,6 +49,7 @@ describe("kube auth proxy tests", () => {
it("calling exit multiple times shouldn't throw", async () => {
const port = await getFreePort();
const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {});
kap.exit();
kap.exit();
kap.exit();
@ -69,24 +70,29 @@ describe("kube auth proxy tests", () => {
jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValueOnce(Promise.resolve(false));
mockedCP.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): ChildProcess => {
listeners[event] = listener;
return mockedCP;
});
mockedCP.stderr = mock<Readable>();
mockedCP.stderr.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners[`stderr/${event}`] = listener;
return mockedCP.stderr;
});
mockedCP.stdout = mock<Readable>();
mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners[`stdout/${event}`] = listener;
return mockedCP.stdout;
});
mockSpawn.mockImplementationOnce((command: string): ChildProcess => {
expect(command).toBe(bundledKubectlPath());
return mockedCP;
});
mockWaitUntilUsed.mockReturnValueOnce(Promise.resolve());
const cluster = new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" });
jest.spyOn(cluster, "apiUrl", "get").mockReturnValue("https://fake.k8s.internal");
proxy = new KubeAuthProxy(cluster, port, {});
});

View File

@ -32,6 +32,7 @@ import { getFreePort } from "../port";
import fse from "fs-extra";
import { loadYaml } from "@kubernetes/client-node";
import { Console } from "console";
import * as path from "path";
console = new Console(process.stdout, process.stderr); // fix mockFS
@ -64,6 +65,7 @@ describe("kubeconfig manager tests", () => {
preferences: {},
})
};
mockFs(mockOpts);
});
@ -83,9 +85,10 @@ describe("kubeconfig manager tests", () => {
const kubeConfManager = await KubeconfigManager.create(cluster, contextHandler, port);
expect(logger.error).not.toBeCalled();
expect(kubeConfManager.getPath()).toBe("tmp/kubeconfig-foo");
expect(kubeConfManager.getPath()).toBe(`tmp${path.sep}kubeconfig-foo`);
const file = await fse.readFile(kubeConfManager.getPath());
const yml = loadYaml<any>(file.toString());
expect(yml["current-context"]).toBe("minikube");
expect(yml["clusters"][0]["cluster"]["server"]).toBe(`http://127.0.0.1:${port}/foo`);
expect(yml["users"][0]["name"]).toBe("proxy");
@ -101,8 +104,8 @@ describe("kubeconfig manager tests", () => {
const contextHandler = new ContextHandler(cluster);
const port = await getFreePort();
const kubeConfManager = await KubeconfigManager.create(cluster, contextHandler, port);
const configPath = kubeConfManager.getPath();
expect(await fse.pathExists(configPath)).toBe(true);
await kubeConfManager.unlink();
expect(await fse.pathExists(configPath)).toBe(false);

View File

@ -14,6 +14,7 @@ export class AppUpdater {
public start() {
setInterval(AppUpdater.checkForUpdates, this.updateInterval);
return AppUpdater.checkForUpdates();
}
}

View File

@ -20,6 +20,7 @@ export class BaseClusterDetector {
protected async k8sRequest<T = any>(path: string, options: RequestPromiseOptions = {}): Promise<T> {
const apiUrl = this.cluster.kubeProxyUrl + path;
return request(apiUrl, {
json: true,
timeout: 30000,

View File

@ -7,17 +7,20 @@ export class ClusterIdDetector extends BaseClusterDetector {
public async detect() {
let id: string;
try {
id = await this.getDefaultNamespaceId();
} catch(_) {
id = this.cluster.apiUrl;
}
const value = createHash("sha256").update(id).digest("hex");
return { value, accuracy: 100 };
}
protected async getDefaultNamespaceId() {
const response = await this.k8sRequest("/api/v1/namespaces/default");
return response.metadata.uid;
}
}

View File

@ -17,12 +17,16 @@ export class DetectorRegistry {
async detectForCluster(cluster: Cluster): Promise<ClusterMetadata> {
const results: {[key: string]: ClusterDetectionResult } = {};
for (const detectorClass of this.registry) {
const detector = new detectorClass(cluster);
try {
const data = await detector.detect();
if (!data) continue;
const existingValue = results[detector.key];
if (existingValue && existingValue.accuracy > data.accuracy) continue; // previous value exists and is more accurate
results[detector.key] = data;
} catch (e) {
@ -30,9 +34,11 @@ export class DetectorRegistry {
}
}
const metadata: ClusterMetadata = {};
for (const [key, result] of Object.entries(results)) {
metadata[key] = result.value;
}
return metadata;
}
}

View File

@ -7,30 +7,59 @@ export class DistributionDetector extends BaseClusterDetector {
public async detect() {
this.version = await this.getKubernetesVersion();
if (await this.isRancher()) {
return { value: "rancher", accuracy: 80};
if (this.isRke()) {
return { value: "rke", accuracy: 80};
}
if (this.isK3s()) {
return { value: "k3s", accuracy: 80};
}
if (this.isGKE()) {
return { value: "gke", accuracy: 80};
}
if (this.isEKS()) {
return { value: "eks", accuracy: 80};
}
if (this.isIKS()) {
return { value: "iks", accuracy: 80};
}
if (this.isAKS()) {
return { value: "aks", accuracy: 80};
}
if (this.isDigitalOcean()) {
return { value: "digitalocean", accuracy: 90};
}
if (this.isMirantis()) {
return { value: "mirantis", accuracy: 90};
}
if (this.isMinikube()) {
return { value: "minikube", accuracy: 80};
}
if (this.isMicrok8s()) {
return { value: "microk8s", accuracy: 80};
}
if (this.isKind()) {
return { value: "kind", accuracy: 70};
}
if (this.isDockerDesktop()) {
return { value: "docker-desktop", accuracy: 80};
}
if (this.isCustom()) {
return { value: "custom", accuracy: 10};
}
return { value: "unknown", accuracy: 10};
}
@ -38,6 +67,7 @@ export class DistributionDetector extends BaseClusterDetector {
if (this.cluster.version) return this.cluster.version;
const response = await this.k8sRequest("/version");
return response.gitVersion;
}
@ -57,6 +87,10 @@ export class DistributionDetector extends BaseClusterDetector {
return this.cluster.apiUrl.endsWith("azmk8s.io");
}
protected isMirantis() {
return this.version.includes("-mirantis-") || this.version.includes("-docker-");
}
protected isDigitalOcean() {
return this.cluster.apiUrl.endsWith("k8s.ondigitalocean.com");
}
@ -65,16 +99,27 @@ export class DistributionDetector extends BaseClusterDetector {
return this.cluster.contextName.startsWith("minikube");
}
protected isMicrok8s() {
return this.cluster.contextName.startsWith("microk8s");
}
protected isKind() {
return this.cluster.contextName.startsWith("kubernetes-admin@kind-");
}
protected isDockerDesktop() {
return this.cluster.contextName === "docker-desktop";
}
protected isCustom() {
return this.version.includes("+");
}
protected async isRancher() {
try {
const response = await this.k8sRequest("");
return response.data.find((api: any) => api?.apiVersion?.group === "meta.cattle.io") !== undefined;
} catch (e) {
return false;
}
protected isRke() {
return this.version.includes("-rancher");
}
}
protected isK3s() {
return this.version.includes("+k3s");
}
}

View File

@ -8,6 +8,7 @@ export class LastSeenDetector extends BaseClusterDetector {
if (!this.cluster.accessible) return null;
await this.k8sRequest("/version");
return { value: new Date().toJSON(), accuracy: 100 };
}
}

View File

@ -7,11 +7,13 @@ export class NodesCountDetector extends BaseClusterDetector {
public async detect() {
if (!this.cluster.accessible) return null;
const nodeCount = await this.getNodeCount();
return { value: nodeCount, accuracy: 100};
}
protected async getNodeCount(): Promise<number> {
const response = await this.k8sRequest("/api/v1/nodes");
return response.items.length;
}
}

View File

@ -7,11 +7,13 @@ export class VersionDetector extends BaseClusterDetector {
public async detect() {
const version = await this.getKubernetesVersion();
return { value: version, accuracy: 100};
}
public async getKubernetesVersion() {
const response = await this.k8sRequest("/version");
return response.gitVersion;
}
}

View File

@ -24,8 +24,10 @@ export class ClusterManager extends Singleton {
// auto-stop removed clusters
autorun(() => {
const removedClusters = Array.from(clusterStore.removedClusters.values());
if (removedClusters.length > 0) {
const meta = removedClusters.map(cluster => cluster.getMeta());
logger.info(`[CLUSTER-MANAGER]: removing clusters`, meta);
removedClusters.forEach(cluster => cluster.disconnect());
clusterStore.removedClusters.clear();
@ -70,7 +72,9 @@ export class ClusterManager extends Singleton {
// lens-server is connecting to 127.0.0.1:<port>/<uid>
if (req.headers.host.startsWith("127.0.0.1")) {
const clusterId = req.url.split("/")[1];
cluster = clusterStore.getById(clusterId);
if (cluster) {
// we need to swap path prefix so that request is proxied to kube api
req.url = req.url.replace(`/${clusterId}`, apiKubePrefix);
@ -79,6 +83,7 @@ export class ClusterManager extends Singleton {
cluster = clusterStore.getById(req.headers["x-cluster-id"].toString());
} else {
const clusterId = getClusterIdFromHost(req.headers.host);
cluster = clusterStore.getById(clusterId);
}

View File

@ -37,6 +37,7 @@ export type ClusterRefreshOptions = {
export interface ClusterState {
initialized: boolean;
enabled: boolean;
apiUrl: string;
online: boolean;
disconnected: boolean;
@ -224,6 +225,7 @@ export class Cluster implements ClusterModel, ClusterState {
*/
@computed get prometheusPreferences(): ClusterPrometheusPreferences {
const { prometheus, prometheusProvider } = this.preferences;
return toJS({ prometheus, prometheusProvider }, {
recurseEverything: true,
});
@ -239,6 +241,7 @@ export class Cluster implements ClusterModel, ClusterState {
constructor(model: ClusterModel) {
this.updateModel(model);
const kubeconfig = this.getKubeconfig();
if (kubeconfig.getContextObject(this.contextName)) {
this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server;
}
@ -324,13 +327,16 @@ export class Cluster implements ClusterModel, ClusterState {
}
logger.info(`[CLUSTER]: activate`, this.getMeta());
await this.whenInitialized;
if (!this.eventDisposers.length) {
this.bindEvents();
}
if (this.disconnected || !this.accessible) {
await this.reconnect();
}
await this.refreshConnectionStatus();
if (this.accessible) {
await this.refreshAllowedResources();
this.isAdmin = await this.isClusterAdmin();
@ -338,6 +344,7 @@ export class Cluster implements ClusterModel, ClusterState {
this.ensureKubectl();
}
this.activated = true;
return this.pushState();
}
@ -346,6 +353,7 @@ export class Cluster implements ClusterModel, ClusterState {
*/
protected async ensureKubectl() {
this.kubeCtl = new Kubectl(this.version);
return this.kubeCtl.ensureKubectl(); // download kubectl in background, so it's not blocking dashboard
}
@ -382,9 +390,11 @@ export class Cluster implements ClusterModel, ClusterState {
logger.info(`[CLUSTER]: refresh`, this.getMeta());
await this.whenInitialized;
await this.refreshConnectionStatus();
if (this.accessible) {
this.isAdmin = await this.isClusterAdmin();
await this.refreshAllowedResources();
if (opts.refreshMetadata) {
this.refreshMetadata();
}
@ -400,6 +410,7 @@ export class Cluster implements ClusterModel, ClusterState {
logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta());
const metadata = await detectorRegistry.detectForCluster(this);
const existingMetadata = this.metadata;
this.metadata = Object.assign(existingMetadata, metadata);
}
@ -408,6 +419,7 @@ export class Cluster implements ClusterModel, ClusterState {
*/
@action async refreshConnectionStatus() {
const connectionStatus = await this.getConnectionStatus();
this.online = connectionStatus > ClusterStatus.Offline;
this.accessible = connectionStatus == ClusterStatus.AccessGranted;
}
@ -456,6 +468,7 @@ export class Cluster implements ClusterModel, ClusterState {
getMetrics(prometheusPath: string, queryParams: IMetricsReqParams & { query: string }) {
const prometheusPrefix = this.preferences.prometheus?.prefix || "";
const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`;
return this.k8sRequest(metricsPath, {
timeout: 0,
resolveWithFullResponse: false,
@ -468,28 +481,36 @@ export class Cluster implements ClusterModel, ClusterState {
try {
const versionDetector = new VersionDetector(this);
const versionData = await versionDetector.detect();
this.metadata.version = versionData.value;
return ClusterStatus.AccessGranted;
} catch (error) {
logger.error(`Failed to connect cluster "${this.contextName}": ${error}`);
if (error.statusCode) {
if (error.statusCode >= 400 && error.statusCode < 500) {
this.failureReason = "Invalid credentials";
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;
}
}
this.failureReason = error.message;
return ClusterStatus.Offline;
}
}
@ -500,15 +521,18 @@ export class Cluster implements ClusterModel, ClusterState {
*/
async canI(resourceAttributes: V1ResourceAttributes): Promise<boolean> {
const authApi = this.getProxyKubeconfig().makeApiClient(AuthorizationV1Api);
try {
const accessReview = await authApi.createSelfSubjectAccessReview({
apiVersion: "authorization.k8s.io/v1",
kind: "SelfSubjectAccessReview",
spec: { resourceAttributes }
});
return accessReview.body.status.allowed;
} catch (error) {
logger.error(`failed to request selfSubjectAccessReview: ${error}`);
return false;
}
}
@ -535,6 +559,7 @@ export class Cluster implements ClusterModel, ClusterState {
ownerRef: this.ownerRef,
accessibleNamespaces: this.accessibleNamespaces,
};
return toJS(model, {
recurseEverything: true
});
@ -546,6 +571,7 @@ export class Cluster implements ClusterModel, ClusterState {
getState(): ClusterState {
const state: ClusterState = {
initialized: this.initialized,
enabled: this.enabled,
apiUrl: this.apiUrl,
online: this.online,
ready: this.ready,
@ -556,6 +582,7 @@ export class Cluster implements ClusterModel, ClusterState {
allowedNamespaces: this.allowedNamespaces,
allowedResources: this.allowedResources,
};
return toJS(state, {
recurseEverything: true
});
@ -597,21 +624,16 @@ export class Cluster implements ClusterModel, ClusterState {
}
const api = this.getProxyKubeconfig().makeApiClient(CoreV1Api);
try {
const namespaceList = await api.listNamespace();
const nsAccessStatuses = await Promise.all(
namespaceList.body.items.map(ns => this.canI({
namespace: ns.metadata.name,
resource: "pods",
verb: "list",
}))
);
return namespaceList.body.items
.filter((ns, i) => nsAccessStatuses[i])
.map(ns => ns.metadata.name);
return namespaceList.body.items.map(ns => ns.metadata.name);
} catch (error) {
const ctx = this.getProxyKubeconfig().getContextObject(this.contextName);
if (ctx.namespace) return [ctx.namespace];
return [];
}
}
@ -629,6 +651,7 @@ export class Cluster implements ClusterModel, ClusterState {
namespace: this.allowedNamespaces[0]
}))
);
return apiResources
.filter((resource, i) => resourceAccessStatuses[i])
.map(apiResource => apiResource.resource);

View File

@ -25,28 +25,34 @@ export class ContextHandler {
public setupPrometheus(preferences: ClusterPrometheusPreferences = {}) {
this.prometheusProvider = preferences.prometheusProvider?.type;
this.prometheusPath = null;
if (preferences.prometheus) {
const { namespace, service, port } = preferences.prometheus;
this.prometheusPath = `${namespace}/services/${service}:${port}`;
}
}
protected async resolvePrometheusPath(): Promise<string> {
const prometheusService = await this.getPrometheusService();
if (!prometheusService) return null;
const { service, namespace, port } = prometheusService;
return `${namespace}/services/${service}:${port}`;
}
async getPrometheusProvider() {
if (!this.prometheusProvider) {
const service = await this.getPrometheusService();
if (!service) {
return null;
}
logger.info(`using ${service.id} as prometheus provider`);
this.prometheusProvider = service.id;
}
return prometheusProviders.find(p => p.id === this.prometheusProvider);
}
@ -54,9 +60,11 @@ export class ContextHandler {
const providers = this.prometheusProvider ? prometheusProviders.filter(provider => provider.id == this.prometheusProvider) : prometheusProviders;
const prometheusPromises: Promise<PrometheusService>[] = providers.map(async (provider: PrometheusProvider): Promise<PrometheusService> => {
const apiClient = this.cluster.getProxyKubeconfig().makeApiClient(CoreV1Api);
return await provider.getPrometheusService(apiClient);
});
const resolvedPrometheusServices = await Promise.all(prometheusPromises);
return resolvedPrometheusServices.filter(n => n)[0];
}
@ -64,12 +72,14 @@ export class ContextHandler {
if (!this.prometheusPath) {
this.prometheusPath = await this.resolvePrometheusPath();
}
return this.prometheusPath;
}
async resolveAuthProxyUrl() {
const proxyPort = await this.ensurePort();
const path = this.clusterUrl.path !== "/" ? this.clusterUrl.path : "";
return `http://127.0.0.1:${proxyPort}${path}`;
}
@ -79,14 +89,17 @@ export class ContextHandler {
}
const timeout = isWatchRequest ? 4 * 60 * 60 * 1000 : 30000; // 4 hours for watch request, 30 seconds for the rest
const apiTarget = await this.newApiTarget(timeout);
if (!isWatchRequest) {
this.apiTarget = apiTarget;
}
return apiTarget;
}
protected async newApiTarget(timeout: number): Promise<httpProxy.ServerOptions> {
const proxyUrl = await this.resolveAuthProxyUrl();
return {
target: proxyUrl,
changeOrigin: true,
@ -101,6 +114,7 @@ export class ContextHandler {
if (!this.proxyPort) {
this.proxyPort = await getFreePort();
}
return this.proxyPort;
}
@ -108,6 +122,7 @@ export class ContextHandler {
if (!this.kubeAuthProxy) {
await this.ensurePort();
const proxyEnv = Object.assign({}, process.env);
if (this.cluster.preferences.httpsProxy) {
proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy;
}

View File

@ -8,11 +8,12 @@ import logger from "./logger";
export function exitApp() {
const windowManager = WindowManager.getInstance<WindowManager>();
const clusterManager = ClusterManager.getInstance<ClusterManager>();
appEventBus.emit({ name: "service", action: "close" });
windowManager.hide();
clusterManager.stop();
windowManager?.hide();
clusterManager?.stop();
logger.info("SERVICE:QUIT");
setTimeout(() => {
app.exit();
}, 1000);
}
}

View File

@ -32,11 +32,14 @@ export class FilesystemProvisionerStore extends BaseStore<FSProvisionModel> {
const salt = randomBytes(32).toString("hex");
const hashedName = SHA256(`${extensionName}/${salt}`).toString();
const dirPath = path.resolve(app.getPath("userData"), "extension_data", hashedName);
this.registeredExtensions.set(extensionName, dirPath);
}
const dirPath = this.registeredExtensions.get(extensionName);
await fse.ensureDir(dirPath);
return dirPath;
}

View File

@ -20,32 +20,39 @@ export class HelmChartManager {
public async chart(name: string) {
const charts = await this.charts();
return charts[name];
}
public async charts(): Promise<any> {
try {
const cachedYaml = await this.cachedYaml();
return cachedYaml["entries"];
} catch(error) {
logger.error(error);
return [];
}
}
public async getReadme(name: string, version = "") {
const helm = await helmCli.binaryPath();
if(version && version != "") {
const { stdout } = await promiseExec(`"${helm}" show readme ${this.repo.name}/${name} --version ${version}`).catch((error) => { throw(error.stderr);});
return stdout;
} else {
const { stdout } = await promiseExec(`"${helm}" show readme ${this.repo.name}/${name}`).catch((error) => { throw(error.stderr);});
return stdout;
}
}
public async getValues(name: string, version = "") {
const helm = await helmCli.binaryPath();
if(version && version != "") {
const { stdout } = await promiseExec(`"${helm}" show values ${this.repo.name}/${name} --version ${version}`).catch((error) => { throw(error.stderr);});
@ -61,6 +68,7 @@ export class HelmChartManager {
if (!(this.repo.name in this.cache)) {
const cacheFile = await fs.promises.readFile(this.repo.cacheFilePath, "utf-8");
const data = yaml.safeLoad(cacheFile);
for(const key in data["entries"]) {
data["entries"][key].forEach((version: any) => {
version["repo"] = this.repo.name;
@ -69,6 +77,7 @@ export class HelmChartManager {
}
this.cache[this.repo.name] = Buffer.from(JSON.stringify(data));
}
return JSON.parse(this.cache[this.repo.name].toString());
}
}

View File

@ -12,6 +12,7 @@ export class HelmCli extends LensBinary {
originalBinaryName: "helm",
newBinaryName: "helm3"
};
super(opts);
}

View File

@ -12,14 +12,15 @@ export class HelmReleaseManager {
const helm = await helmCli.binaryPath();
const namespaceFlag = namespace ? `-n ${namespace}` : "--all-namespaces";
const { stdout } = await promiseExec(`"${helm}" ls --output json ${namespaceFlag} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);});
const output = JSON.parse(stdout);
if (output.length == 0) {
return output;
}
output.forEach((release: any, index: number) => {
output[index] = toCamelCase(release);
});
return output;
}
@ -27,15 +28,19 @@ export class HelmReleaseManager {
public async installChart(chart: string, values: any, name: string, namespace: string, version: string, pathToKubeconfig: string){
const helm = await helmCli.binaryPath();
const fileName = tempy.file({name: "values.yaml"});
await fs.promises.writeFile(fileName, yaml.safeDump(values));
try {
let generateName = "";
if (!name) {
generateName = "--generate-name";
name = "";
}
const { stdout } = await promiseExec(`"${helm}" install ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} ${generateName}`).catch((error) => { throw(error.stderr);});
const releaseName = stdout.split("\n")[0].split(" ")[1].trim();
return {
log: stdout,
release: {
@ -51,10 +56,12 @@ export class HelmReleaseManager {
public async upgradeRelease(name: string, chart: string, values: any, namespace: string, version: string, cluster: Cluster){
const helm = await helmCli.binaryPath();
const fileName = tempy.file({name: "values.yaml"});
await fs.promises.writeFile(fileName, yaml.safeDump(values));
try {
const { stdout } = await promiseExec(`"${helm}" upgrade ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr);});
return {
log: stdout,
release: this.getRelease(name, namespace, cluster)
@ -68,7 +75,9 @@ export class HelmReleaseManager {
const helm = await helmCli.binaryPath();
const { stdout } = await promiseExec(`"${helm}" status ${name} --output json --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr);});
const release = JSON.parse(stdout);
release.resources = await this.getResources(name, namespace, cluster);
return release;
}
@ -82,18 +91,21 @@ export class HelmReleaseManager {
public async getValues(name: string, namespace: string, pathToKubeconfig: string) {
const helm = await helmCli.binaryPath();
const { stdout, } = await promiseExec(`"${helm}" get values ${name} --all --output yaml --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);});
return stdout;
}
public async getHistory(name: string, namespace: string, pathToKubeconfig: string) {
const helm = await helmCli.binaryPath();
const { stdout } = await promiseExec(`"${helm}" history ${name} --output json --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);});
return JSON.parse(stdout);
}
public async rollback(name: string, namespace: string, revision: number, pathToKubeconfig: string) {
const helm = await helmCli.binaryPath();
const { stdout } = await promiseExec(`"${helm}" rollback ${name} ${revision} --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);});
return stdout;
}
@ -104,6 +116,7 @@ export class HelmReleaseManager {
const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectl}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`).catch(() => {
return { stdout: JSON.stringify({items: []})};
});
return stdout;
}
}

View File

@ -42,12 +42,14 @@ export class HelmRepoManager extends Singleton {
resolveWithFullResponse: true,
timeout: 10000,
});
return orderBy<HelmRepo>(res.body, repo => repo.name);
}
async init() {
helmCli.setLogger(logger);
await helmCli.ensureBinary();
if (!this.initialized) {
this.helmEnv = await this.parseHelmEnv();
await this.update();
@ -62,12 +64,15 @@ export class HelmRepoManager extends Singleton {
});
const lines = stdout.split(/\r?\n/); // split by new line feed
const env: HelmEnv = {};
lines.forEach((line: string) => {
const [key, value] = line.split("=");
if (key && value) {
env[key] = value.replace(/"/g, ""); // strip quotas
}
});
return env;
}
@ -75,6 +80,7 @@ export class HelmRepoManager extends Singleton {
if (!this.initialized) {
await this.init();
}
try {
const repoConfigFile = this.helmEnv.HELM_REPOSITORY_CONFIG;
const { repositories }: HelmRepoConfig = await readFile(repoConfigFile, "utf8")
@ -82,22 +88,27 @@ export class HelmRepoManager extends Singleton {
.catch(() => ({
repositories: []
}));
if (!repositories.length) {
await this.addRepo({ name: "bitnami", url: "https://charts.bitnami.com/bitnami" });
return await this.repositories();
}
return repositories.map(repo => ({
...repo,
cacheFilePath: `${this.helmEnv.HELM_REPOSITORY_CACHE}/${repo.name}-index.yaml`
}));
} catch (error) {
logger.error(`[HELM]: repositories listing error "${error}"`);
return [];
}
}
public async repository(name: string) {
const repositories = await this.repositories();
return repositories.find(repo => repo.name == name);
}
@ -106,6 +117,7 @@ export class HelmRepoManager extends Singleton {
const { stdout } = await promiseExec(`"${helm}" repo update`).catch((error) => {
return { stdout: error.stdout };
});
return stdout;
}
@ -115,6 +127,7 @@ export class HelmRepoManager extends Singleton {
const { stdout } = await promiseExec(`"${helm}" repo add ${name} ${url}`).catch((error) => {
throw(error.stderr);
});
return stdout;
}
@ -124,6 +137,7 @@ export class HelmRepoManager extends Singleton {
const { stdout } = await promiseExec(`"${helm}" repo remove ${name}`).catch((error) => {
throw(error.stderr);
});
return stdout;
}
}

View File

@ -11,18 +11,23 @@ class HelmService {
public async listCharts() {
const charts: any = {};
await repoManager.init();
const repositories = await repoManager.repositories();
for (const repo of repositories) {
charts[repo.name] = {};
const manager = new HelmChartManager(repo);
let entries = await manager.charts();
entries = this.excludeDeprecated(entries);
for (const key in entries) {
entries[key] = entries[key][0];
}
charts[repo.name] = entries;
}
return charts;
}
@ -34,50 +39,60 @@ class HelmService {
const repo = await repoManager.repository(repoName);
const chartManager = new HelmChartManager(repo);
const chart = await chartManager.chart(chartName);
result.readme = await chartManager.getReadme(chartName, version);
result.versions = chart;
return result;
}
public async getChartValues(repoName: string, chartName: string, version = "") {
const repo = await repoManager.repository(repoName);
const chartManager = new HelmChartManager(repo);
return chartManager.getValues(chartName, version);
}
public async listReleases(cluster: Cluster, namespace: string = null) {
await repoManager.init();
return await releaseManager.listReleases(cluster.getProxyKubeconfigPath(), namespace);
}
public async getRelease(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Fetch release");
return await releaseManager.getRelease(releaseName, namespace, cluster);
}
public async getReleaseValues(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Fetch release values");
return await releaseManager.getValues(releaseName, namespace, cluster.getProxyKubeconfigPath());
}
public async getReleaseHistory(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Fetch release history");
return await releaseManager.getHistory(releaseName, namespace, cluster.getProxyKubeconfigPath());
}
public async deleteRelease(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Delete release");
return await releaseManager.deleteRelease(releaseName, namespace, cluster.getProxyKubeconfigPath());
}
public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: { chart: string; values: {}; version: string }) {
logger.debug("Upgrade release");
return await releaseManager.upgradeRelease(releaseName, data.chart, data.values, namespace, data.version, cluster);
}
public async rollback(cluster: Cluster, releaseName: string, namespace: string, revision: number) {
logger.debug("Rollback release");
const output = await releaseManager.rollback(releaseName, namespace, revision, cluster.getProxyKubeconfigPath());
return { message: output };
}
@ -87,9 +102,11 @@ class HelmService {
if (Array.isArray(entry)) {
return entry[0]["deprecated"] != true;
}
return entry["deprecated"] != true;
});
}
return entries;
}

View File

@ -34,11 +34,13 @@ let clusterManager: ClusterManager;
let windowManager: WindowManager;
app.setName(appName);
if (!process.env.CICD) {
app.setPath("userData", workingDir);
}
mangleProxyEnv();
if (app.commandLine.getSwitchValue("proxy-server") !== "") {
process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server");
}
@ -48,6 +50,7 @@ app.on("ready", async () => {
await shellSync();
const updater = new AppUpdater();
updater.start();
registerFileProtocol("static", __static);
@ -110,6 +113,7 @@ app.on("ready", async () => {
app.on("activate", (event, hasVisibleWindows) => {
logger.info("APP:ACTIVATE", { hasVisibleWindows });
if (!hasVisibleWindows) {
windowManager.initMainWindow();
}
@ -121,6 +125,7 @@ app.on("will-quit", (event) => {
appEventBus.emit({name: "app", action: "close"});
event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.)
clusterManager?.stop(); // close cluster connections
return; // skip exit to make tray work, to quit go to app's global menu or tray's menu
});

View File

@ -45,6 +45,7 @@ export class KubeAuthProxy {
"--accept-hosts", this.acceptHosts,
"--reject-paths", "^[^/]"
];
if (process.env.DEBUG_PROXY === "true") {
args.push("-v", "9");
}
@ -62,6 +63,7 @@ export class KubeAuthProxy {
this.proxyProcess.stdout.on("data", (data) => {
let logItem = data.toString();
if (logItem.startsWith("Starting to serve on")) {
logItem = "Authentication proxy started\n";
}
@ -80,19 +82,23 @@ export class KubeAuthProxy {
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 async 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);
}

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