Cherry-pick from 4.2.3 (#2628)

* Fix: logs data disapearing causing crashes (#2566)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Refactor helm-chart.api and improve kube validation and error handling (#2265)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix: HPA's not sortable by age (#2565)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Conditionally render status icon for kube meta (#2298)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix custom resource loading spinner appears above extensions' cluster menus (#2344)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Lens should point to the release docs (#2268)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Refactor the Extensions settings page (#2221)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* try and get jest to not core dump

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton authored and GitHub committed 2021-04-27 08:11:50 +03:00
1 parent 7fde8125eb
commit 1f854d0a0f
42 files changed
+1777 -1067

No files matched your search

@@ -1,10 +1,14 @@
import mockFs from "mock-fs";
import { watch } from "chokidar";
import { join, normalize } from "path";
import { ExtensionDiscovery, InstalledExtension } from "../extension-discovery";
import { ExtensionsStore } from "../extensions-store";
import path from "path";
import { ExtensionDiscovery } from "../extension-discovery";
import os from "os";
import { Console } from "console";
jest.setTimeout(60_000);
jest.mock("../../common/ipc");
jest.mock("fs-extra");
jest.mock("chokidar", () => ({
watch: jest.fn()
}));
@@ -15,6 +19,7 @@ jest.mock("../extension-installer", () => ({
}
}));
console = new Console(process.stdout, process.stderr); // fix mockFS
const mockedWatch = watch as jest.MockedFunction<typeof watch>;
describe("ExtensionDiscovery", () => {
@@ -24,47 +29,59 @@ describe("ExtensionDiscovery", () => {
ExtensionsStore.createInstance();
});
it("emits add for added extension", async done => {
globalThis.__non_webpack_require__.mockImplementation(() => ({
name: "my-extension"
}));
let addHandler: (filePath: string) => void;
const mockWatchInstance: any = {
on: jest.fn((event: string, handler: typeof addHandler) => {
if (event === "add") {
addHandler = handler;
}
return mockWatchInstance;
})
};
mockedWatch.mockImplementationOnce(() =>
(mockWatchInstance) as any
);
const extensionDiscovery = ExtensionDiscovery.createInstance();
// Need to force isLoaded to be true so that the file watching is started
extensionDiscovery.isLoaded = true;
await extensionDiscovery.watchExtensions();
extensionDiscovery.events.on("add", (extension: InstalledExtension) => {
expect(extension).toEqual({
absolutePath: expect.any(String),
id: normalize("node_modules/my-extension/package.json"),
isBundled: false,
isEnabled: false,
manifest: {
name: "my-extension",
},
manifestPath: normalize("node_modules/my-extension/package.json"),
describe("with mockFs", () => {
beforeEach(() => {
mockFs({
[`${os.homedir()}/.k8slens/extensions/my-extension/package.json`]: JSON.stringify({
name: "my-extension"
}),
});
done();
});
addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/package.json"));
afterEach(() => {
mockFs.restore();
});
it("emits add for added extension", async (done) => {
let addHandler: (filePath: string) => void;
const mockWatchInstance: any = {
on: jest.fn((event: string, handler: typeof addHandler) => {
if (event === "add") {
addHandler = handler;
}
return mockWatchInstance;
})
};
mockedWatch.mockImplementationOnce(() =>
(mockWatchInstance) as any
);
const extensionDiscovery = ExtensionDiscovery.createInstance();
// Need to force isLoaded to be true so that the file watching is started
extensionDiscovery.isLoaded = true;
await extensionDiscovery.watchExtensions();
extensionDiscovery.events.on("add", extension => {
expect(extension).toEqual({
absolutePath: expect.any(String),
id: path.normalize("node_modules/my-extension/package.json"),
isBundled: false,
isEnabled: false,
manifest: {
name: "my-extension",
},
manifestPath: path.normalize("node_modules/my-extension/package.json"),
});
done();
});
addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/package.json"));
});
});
it("doesn't emit add for added file under extension", async done => {
@@ -94,7 +111,7 @@ describe("ExtensionDiscovery", () => {
extensionDiscovery.events.on("add", onAdd);
addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json"));
addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json"));
setTimeout(() => {
expect(onAdd).not.toHaveBeenCalled();
+76 -87
View File
@@ -1,31 +1,33 @@
import { watch } from "chokidar";
import { ipcRenderer } from "electron";
import { EventEmitter } from "events";
import fs from "fs-extra";
import fse from "fs-extra";
import { observable, reaction, toJS, when } from "mobx";
import os from "os";
import path from "path";
import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc";
import { Singleton } from "../common/utils";
import logger from "../main/logger";
import { ExtensionInstallationStateStore } from "../renderer/components/+extensions/extension-install.store";
import { extensionInstaller, PackageJson } from "./extension-installer";
import { ExtensionsStore } from "./extensions-store";
import { ExtensionLoader } from "./extension-loader";
import type { LensExtensionId, LensExtensionManifest } from "./lens-extension";
export interface InstalledExtension {
id: LensExtensionId;
id: LensExtensionId;
readonly manifest: LensExtensionManifest;
readonly manifest: LensExtensionManifest;
// Absolute path to the non-symlinked source folder,
// e.g. "/Users/user/.k8slens/extensions/helloworld"
readonly absolutePath: string;
// Absolute path to the non-symlinked source folder,
// e.g. "/Users/user/.k8slens/extensions/helloworld"
readonly absolutePath: string;
// Absolute to the symlinked package.json file
readonly manifestPath: string;
readonly isBundled: boolean; // defined in project root's package.json
isEnabled: boolean;
}
// Absolute to the symlinked package.json file
readonly manifestPath: string;
readonly isBundled: boolean; // defined in project root's package.json
isEnabled: boolean;
}
const logModule = "[EXTENSION-DISCOVERY]";
@@ -39,7 +41,7 @@ interface ExtensionDiscoveryChannelMessage {
* Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link)
* @param lstat the stats to compare
*/
const isDirectoryLike = (lstat: fs.Stats) => lstat.isDirectory() || lstat.isSymbolicLink();
const isDirectoryLike = (lstat: fse.Stats) => lstat.isDirectory() || lstat.isSymbolicLink();
/**
* Discovers installed bundled and local extensions from the filesystem.
@@ -64,12 +66,7 @@ export class ExtensionDiscovery extends Singleton {
// IPC channel to broadcast changes to extension-discovery from main
protected static readonly extensionDiscoveryChannel = "extension-discovery:main";
public events: EventEmitter;
constructor() {
super();
this.events = new EventEmitter();
}
public events = new EventEmitter();
get localFolderPath(): string {
return path.join(os.homedir(), ".k8slens", "extensions");
@@ -146,8 +143,10 @@ export class ExtensionDiscovery extends Singleton {
})
// 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);
// Extension remove is detected by watching "<extensionDir>" unlink
.on("unlinkDir", this.handleWatchUnlinkEvent)
// Extension remove is detected by watching "<extensionSymLink>" unlink
.on("unlink", this.handleWatchUnlinkEvent);
}
handleWatchFileAdd = async (manifestPath: string) => {
@@ -161,6 +160,7 @@ export class ExtensionDiscovery extends Singleton {
if (path.basename(manifestPath) === manifestFilename && isUnderLocalFolderPath) {
try {
ExtensionInstallationStateStore.setInstallingFromMain(manifestPath);
const absPath = path.dirname(manifestPath);
// this.loadExtensionFromPath updates this.packagesJson
@@ -168,7 +168,7 @@ export class ExtensionDiscovery extends Singleton {
if (extension) {
// Remove a broken symlink left by a previous installation if it exists.
await this.removeSymlinkByManifestPath(manifestPath);
await fse.remove(extension.manifestPath);
// Install dependencies for the new extension
await this.installPackage(extension.absolutePath);
@@ -178,40 +178,46 @@ export class ExtensionDiscovery extends Singleton {
this.events.emit("add", extension);
}
} catch (error) {
console.error(error);
logger.error(`${logModule}: failed to add extension: ${error}`, { error });
} finally {
ExtensionInstallationStateStore.clearInstallingFromMain(manifestPath);
}
}
};
handleWatchUnlinkDir = async (filePath: string) => {
// filePath is the non-symlinked path to the extension folder
// this.packagesJson.dependencies value is the non-symlinked path to the extension folder
// LensExtensionId in extension-loader is the symlinked path to the extension folder manifest file
/**
* Handle any unlink event, filtering out non-package.json links so the delete code
* only happens once per extension.
* @param filePath The absolute path to either a folder or file in the extensions folder
*/
handleWatchUnlinkEvent = async (filePath: string): Promise<void> => {
// Check that the removed path is directly under this.localFolderPath
// Note that the watcher can create unlink events for subdirectories of the extension
const extensionFolderName = path.basename(filePath);
const expectedPath = path.relative(this.localFolderPath, filePath);
if (path.relative(this.localFolderPath, filePath) === extensionFolderName) {
const extension = Array.from(this.extensions.values()).find((extension) => extension.absolutePath === filePath);
if (extension) {
const extensionName = extension.manifest.name;
// If the extension is deleted manually while the application is running, also remove the symlink
await this.removeSymlinkByPackageName(extensionName);
// The path to the manifest file is the lens extension id
// Note that we need to use the symlinked path
const lensExtensionId = extension.manifestPath;
this.extensions.delete(extension.id);
logger.info(`${logModule} removed extension ${extensionName}`);
this.events.emit("remove", lensExtensionId as LensExtensionId);
} else {
logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`);
}
if (expectedPath !== extensionFolderName) {
return;
}
const extension = Array.from(this.extensions.values()).find((extension) => extension.absolutePath === filePath);
if (!extension) {
return void logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`);
}
const extensionName = extension.manifest.name;
// If the extension is deleted manually while the application is running, also remove the symlink
await this.removeSymlinkByPackageName(extensionName);
// The path to the manifest file is the lens extension id
// Note: that we need to use the symlinked path
const lensExtensionId = extension.manifestPath;
this.extensions.delete(extension.id);
logger.info(`${logModule} removed extension ${extensionName}`);
this.events.emit("remove", lensExtensionId);
};
/**
@@ -221,31 +227,23 @@ export class ExtensionDiscovery extends Singleton {
* @param name e.g. "@mirantis/lens-extension-cc"
*/
removeSymlinkByPackageName(name: string) {
return fs.remove(this.getInstalledPath(name));
}
/**
* Remove the symlink under node_modules if it exists.
* @param manifestPath Path to package.json
*/
removeSymlinkByManifestPath(manifestPath: string) {
const manifestJson = __non_webpack_require__(manifestPath);
return this.removeSymlinkByPackageName(manifestJson.name);
return fse.remove(this.getInstalledPath(name));
}
/**
* Uninstalls extension.
* The application will detect the folder unlink and remove the extension from the UI automatically.
* @param extension Extension to uninstall.
* @param extensionId The ID of the extension to uninstall.
*/
async uninstallExtension({ absolutePath, manifest }: InstalledExtension) {
async uninstallExtension(extensionId: LensExtensionId) {
const { manifest, absolutePath } = this.extensions.get(extensionId) ?? ExtensionLoader.getInstance().getExtension(extensionId);
logger.info(`${logModule} Uninstalling ${manifest.name}`);
await this.removeSymlinkByPackageName(manifest.name);
// fs.remove does nothing if the path doesn't exist anymore
await fs.remove(absolutePath);
await fse.remove(absolutePath);
}
async load(): Promise<Map<LensExtensionId, InstalledExtension>> {
@@ -259,12 +257,11 @@ export class ExtensionDiscovery extends Singleton {
logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`);
// fs.remove won't throw if path is missing
await fs.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json"));
await fse.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json"));
try {
// Verify write access to static/extensions, which is needed for symlinking
await fs.access(this.inTreeFolderPath, fs.constants.W_OK);
await fse.access(this.inTreeFolderPath, fse.constants.W_OK);
// Set bundled folder path to static/extensions
this.bundledFolderPath = this.inTreeFolderPath;
@@ -273,20 +270,20 @@ export class ExtensionDiscovery extends Singleton {
// The error can happen if there is read-only rights to static/extensions, which would fail symlinking.
// Remove e.g. /Users/<username>/Library/Application Support/LensDev/extensions
await fs.remove(this.inTreeTargetPath);
await fse.remove(this.inTreeTargetPath);
// Create folder e.g. /Users/<username>/Library/Application Support/LensDev/extensions
await fs.ensureDir(this.inTreeTargetPath);
await fse.ensureDir(this.inTreeTargetPath);
// Copy static/extensions to e.g. /Users/<username>/Library/Application Support/LensDev/extensions
await fs.copy(this.inTreeFolderPath, this.inTreeTargetPath);
await fse.copy(this.inTreeFolderPath, this.inTreeTargetPath);
// Set bundled folder path to e.g. /Users/<username>/Library/Application Support/LensDev/extensions
this.bundledFolderPath = this.inTreeTargetPath;
}
await fs.ensureDir(this.nodeModulesPath);
await fs.ensureDir(this.localFolderPath);
await fse.ensureDir(this.nodeModulesPath);
await fse.ensureDir(this.localFolderPath);
const extensions = await this.ensureExtensions();
@@ -315,30 +312,22 @@ export class ExtensionDiscovery extends Singleton {
* Returns InstalledExtension from path to package.json file.
* Also updates this.packagesJson.
*/
protected async getByManifest(manifestPath: string, { isBundled = false }: {
isBundled?: boolean;
} = {}): Promise<InstalledExtension | null> {
let manifestJson: LensExtensionManifest;
protected async getByManifest(manifestPath: string, { isBundled = false } = {}): Promise<InstalledExtension | null> {
try {
// check manifest file for existence
fs.accessSync(manifestPath, fs.constants.F_OK);
manifestJson = __non_webpack_require__(manifestPath);
const installedManifestPath = this.getInstalledManifestPath(manifestJson.name);
const isEnabled = isBundled || ExtensionsStore.getInstance().isEnabled(installedManifestPath);
const manifest = await fse.readJson(manifestPath);
const installedManifestPath = this.getInstalledManifestPath(manifest.name);
const isEnabled = isBundled || ExtensionsStore.getInstance().isEnabled(installedManifestPath);
return {
id: installedManifestPath,
absolutePath: path.dirname(manifestPath),
manifestPath: installedManifestPath,
manifest: manifestJson,
manifest,
isBundled,
isEnabled
};
} catch (error) {
logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`, { manifestJson });
logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`);
return null;
}
@@ -352,7 +341,7 @@ export class ExtensionDiscovery extends Singleton {
const userExtensions = await this.loadFromFolder(this.localFolderPath, bundledExtensions.map((extension) => extension.manifest.name));
for (const extension of userExtensions) {
if (await fs.pathExists(extension.manifestPath) === false) {
if (await fse.pathExists(extension.manifestPath) === false) {
await this.installPackage(extension.absolutePath);
}
}
@@ -383,7 +372,7 @@ export class ExtensionDiscovery extends Singleton {
async loadBundledExtensions() {
const extensions: InstalledExtension[] = [];
const folderPath = this.bundledFolderPath;
const paths = await fs.readdir(folderPath);
const paths = await fse.readdir(folderPath);
for (const fileName of paths) {
const absPath = path.resolve(folderPath, fileName);
@@ -400,7 +389,7 @@ export class ExtensionDiscovery extends Singleton {
async loadFromFolder(folderPath: string, bundledExtensions: string[]): Promise<InstalledExtension[]> {
const extensions: InstalledExtension[] = [];
const paths = await fs.readdir(folderPath);
const paths = await fse.readdir(folderPath);
for (const fileName of paths) {
// do not allow to override bundled extensions
@@ -410,11 +399,11 @@ export class ExtensionDiscovery extends Singleton {
const absPath = path.resolve(folderPath, fileName);
if (!fs.existsSync(absPath)) {
if (!fse.existsSync(absPath)) {
continue;
}
const lstat = await fs.lstat(absPath);
const lstat = await fse.lstat(absPath);
// skip non-directories
if (!isDirectoryLike(lstat)) {
+12 -22
View File
@@ -13,8 +13,6 @@ import type { LensExtension, LensExtensionConstructor, LensExtensionId } from ".
import type { LensMainExtension } from "./lens-main-extension";
import type { LensRendererExtension } from "./lens-renderer-extension";
import * as registries from "./registries";
import fs from "fs";
export function extensionPackagesRoot() {
return path.join((app || remote.app).getPath("userData"));
@@ -290,28 +288,20 @@ export class ExtensionLoader extends Singleton {
});
}
protected requireExtension(extension: InstalledExtension): LensExtensionConstructor {
let extEntrypoint = "";
protected requireExtension(extension: InstalledExtension): LensExtensionConstructor | null {
const entryPointName = ipcRenderer ? "renderer" : "main";
const extRelativePath = extension.manifest[entryPointName];
if (!extRelativePath) {
return null;
}
const extAbsolutePath = path.resolve(path.join(path.dirname(extension.manifestPath), extRelativePath));
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 !== "") {
if (!fs.existsSync(extEntrypoint)) {
console.log(`${logModule}: entrypoint ${extEntrypoint} not found, skipping ...`);
return;
}
return __non_webpack_require__(extEntrypoint).default;
}
} catch (err) {
console.error(`${logModule}: can't load extension main at ${extEntrypoint}: ${err}`, { extension });
console.trace(err);
return __non_webpack_require__(extAbsolutePath).default;
} catch (error) {
logger.error(`${logModule}: can't load extension main at ${extAbsolutePath}: ${error}`, { extension, error });
}
}
@@ -9,9 +9,17 @@ export interface KubeObjectStatusRegistration {
export class KubeObjectStatusRegistry extends BaseRegistry<KubeObjectStatusRegistration> {
getItemsForKind(kind: string, apiVersion: string) {
return this.getItems().filter((item) => {
return item.kind === kind && item.apiVersions.includes(apiVersion);
});
return this.getItems()
.filter((item) => (
item.kind === kind
&& item.apiVersions.includes(apiVersion)
));
}
getItemsForObject(src: KubeObject) {
return this.getItemsForKind(src.kind, src.apiVersion)
.map(item => item.resolve(src))
.filter(Boolean);
}
}