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

Add the ability to sync kube config files (#2567)

* Add the ability to sync kube config files

- Will update when the files changes

- add KUBECONFIG_SYNC label

- fix rebase and change to addObservableSource

- move UI to user settings

- support shallow folder watching

- add some unit tests for the diff-er

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

* responding to review comments

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

* fix tests and add try/catch

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

* always sync c&p folder, remove bad rebase

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

* fix preferences

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

* Fix settings saving and catalog view

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

* fix unit tests

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

* fix synced clusters not connectable

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

* change to non-complete shallow watching

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

* fix sizing

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

* Catch readStream errors

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

* don't clear UserStore on non-existant preference field, fix unlinking not removing items from source

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

* change label to file

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2021-04-30 09:48:20 -04:00 committed by GitHub
parent f06d330835
commit 998f7aa934
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1215 additions and 236 deletions

View File

@ -53,6 +53,9 @@ export async function addMinikubeCluster(app: Application) {
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
await app.client.waitUntilTextExists("div.TableCell", "minikube");
await app.client.waitForExist(".Input.SearchInput input");
await app.client.setValue(".Input.SearchInput input", "minikube");
await app.client.waitUntilTextExists("div.TableCell", "minikube");
await app.client.click("div.TableRow");
}

View File

@ -237,6 +237,7 @@
"devDependencies": {
"@emeraldpay/hashicon-react": "^0.4.0",
"@material-ui/core": "^4.10.1",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "^4.0.0-alpha.57",
"@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
"@testing-library/jest-dom": "^5.11.10",
@ -254,7 +255,7 @@
"@types/hoist-non-react-statics": "^3.3.1",
"@types/html-webpack-plugin": "^3.2.3",
"@types/http-proxy": "^1.17.5",
"@types/jest": "^25.2.3",
"@types/jest": "^26.0.22",
"@types/js-yaml": "^3.12.4",
"@types/jsdom": "^16.2.4",
"@types/jsonpath": "^0.2.0",
@ -344,7 +345,7 @@
"sharp": "^0.26.1",
"spectron": "11.0.0",
"style-loader": "^1.2.1",
"ts-jest": "^26.1.0",
"ts-jest": "26.3.0",
"ts-loader": "^7.0.5",
"ts-node": "^8.10.2",
"type-fest": "^1.0.2",

View File

@ -27,7 +27,7 @@ describe("CatalogEntityRegistry", () => {
it ("allows to add an observable source", () => {
const source = observable.array([]);
registry.addSource("test", source);
registry.addObservableSource("test", source);
expect(registry.items.length).toEqual(0);
source.push(entity);
@ -38,7 +38,7 @@ describe("CatalogEntityRegistry", () => {
it ("added source change triggers reaction", (done) => {
const source = observable.array([]);
registry.addSource("test", source);
registry.addObservableSource("test", source);
reaction(() => registry.items, () => {
done();
});
@ -51,7 +51,7 @@ describe("CatalogEntityRegistry", () => {
it ("removes source", () => {
const source = observable.array([]);
registry.addSource("test", source);
registry.addObservableSource("test", source);
source.push(entity);
registry.removeSource("test");

View File

@ -72,55 +72,63 @@ describe("kube helpers", () => {
});
describe("with default validation options", () => {
describe("with valid kubeconfig", () => {
it("does not raise exceptions", () => {
expect(() => { validateKubeConfig(kc, "valid");}).not.toThrow();
it("does not return an error", () => {
expect(validateKubeConfig(kc, "valid")).toBeUndefined();
});
});
describe("with invalid context object", () => {
it("it raises exception", () => {
expect(() => { validateKubeConfig(kc, "invalid");}).toThrow("No valid context object provided in kubeconfig for context 'invalid'");
it("returns an error", () => {
expect(String(validateKubeConfig(kc, "invalid"))).toEqual(
expect.stringContaining("No valid context object provided in kubeconfig for context 'invalid'")
);
});
});
describe("with invalid cluster object", () => {
it("it raises exception", () => {
expect(() => { validateKubeConfig(kc, "invalidCluster");}).toThrow("No valid cluster object provided in kubeconfig for context 'invalidCluster'");
it("returns an error", () => {
expect(String(validateKubeConfig(kc, "invalidCluster"))).toEqual(
expect.stringContaining("No valid cluster object provided in kubeconfig for context 'invalidCluster'")
);
});
});
describe("with invalid user object", () => {
it("it raises exception", () => {
expect(() => { validateKubeConfig(kc, "invalidUser");}).toThrow("No valid user object provided in kubeconfig for context 'invalidUser'");
it("returns an error", () => {
expect(String(validateKubeConfig(kc, "invalidUser"))).toEqual(
expect.stringContaining("No valid user object provided in kubeconfig for context 'invalidUser'")
);
});
});
describe("with invalid exec command", () => {
it("it raises exception", () => {
expect(() => { validateKubeConfig(kc, "invalidExec");}).toThrow("User Exec command \"foo\" not found on host. Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig");
it("returns an error", () => {
expect(String(validateKubeConfig(kc, "invalidExec"))).toEqual(
expect.stringContaining("User Exec command \"foo\" not found on host. Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig")
);
});
});
});
describe("with validateCluster as false", () => {
describe("with invalid cluster object", () => {
it("does not raise exception", () => {
expect(() => { validateKubeConfig(kc, "invalidCluster", { validateCluster: false });}).not.toThrow();
it("does not return an error", () => {
expect(validateKubeConfig(kc, "invalidCluster", { validateCluster: false })).toBeUndefined();
});
});
});
describe("with validateUser as false", () => {
describe("with invalid user object", () => {
it("does not raise exceptions", () => {
expect(() => { validateKubeConfig(kc, "invalidUser", { validateUser: false });}).not.toThrow();
it("does not return an error", () => {
expect(validateKubeConfig(kc, "invalidUser", { validateUser: false })).toBeUndefined();
});
});
});
describe("with validateExec as false", () => {
describe("with invalid exec object", () => {
it("does not raise exceptions", () => {
expect(() => { validateKubeConfig(kc, "invalidExec", { validateExec: false });}).not.toThrow();
it("does not return an error", () => {
expect(validateKubeConfig(kc, "invalidExec", { validateExec: false })).toBeUndefined();
});
});
});

View File

@ -19,12 +19,13 @@ import { UserStore } from "../user-store";
import { SemVer } from "semver";
import electron from "electron";
import { stdout, stderr } from "process";
import { beforeEachWrapped } from "../../../integration/helpers/utils";
console = new Console(stdout, stderr);
describe("user store tests", () => {
describe("for an empty config", () => {
beforeEach(() => {
beforeEachWrapped(() => {
UserStore.resetInstance();
mockFs({ tmp: { "config.json": "{}", "kube_config": "{}" } });
@ -90,7 +91,7 @@ describe("user store tests", () => {
});
describe("migrations", () => {
beforeEach(() => {
beforeEachWrapped(() => {
UserStore.resetInstance();
mockFs({
"tmp": {

View File

@ -94,7 +94,7 @@ export class KubernetesClusterCategory extends CatalogCategory {
ctx.menuItems.push({
icon: "text_snippet",
title: "Add from kubeconfig",
onClick: async () => {
onClick: () => {
ctx.navigate("/add-cluster");
}
});

View File

@ -1,10 +1,15 @@
import { action, computed, observable, IObservableArray } from "mobx";
import { action, computed, observable, IComputedValue, IObservableArray } from "mobx";
import { CatalogEntity } from "./catalog-entity";
import { iter } from "../utils";
export class CatalogEntityRegistry {
protected sources = observable.map<string, IObservableArray<CatalogEntity>>([], { deep: true });
protected sources = observable.map<string, IComputedValue<CatalogEntity[]>>([], { deep: true });
@action addSource(id: string, source: IObservableArray<CatalogEntity>) {
@action addObservableSource(id: string, source: IObservableArray<CatalogEntity>) {
this.sources.set(id, computed(() => source));
}
@action addComputedSource(id: string, source: IComputedValue<CatalogEntity[]>) {
this.sources.set(id, source);
}
@ -13,7 +18,7 @@ export class CatalogEntityRegistry {
}
@computed get items(): CatalogEntity[] {
return Array.from(this.sources.values()).flat();
return Array.from(iter.flatMap(this.sources.values(), source => source.get()));
}
getItemsForApiKind<T extends CatalogEntity>(apiVersion: string, kind: string): T[] {

View File

@ -65,7 +65,7 @@ export interface CatalogEntityContextMenu {
icon: string;
title: string;
onlyVisibleForSource?: string; // show only if empty or if matches with entity source
onClick: () => Promise<void>;
onClick: () => void | Promise<void>;
confirm?: {
message: string;
}

View File

@ -37,6 +37,10 @@ export interface ClusterStoreModel {
export type ClusterId = string;
export interface UpdateClusterModel extends Omit<ClusterModel, "id"> {
id?: ClusterId;
}
export interface ClusterModel {
/** Unique id for a cluster */
id: ClusterId;
@ -94,8 +98,12 @@ export interface ClusterPrometheusPreferences {
}
export class ClusterStore extends BaseStore<ClusterStoreModel> {
static get storedKubeConfigFolder(): string {
return path.resolve((app || remote.app).getPath("userData"), "kubeconfigs");
}
static getCustomKubeConfigPath(clusterId: ClusterId): string {
return path.resolve((app || remote.app).getPath("userData"), "kubeconfigs", clusterId);
return path.resolve(ClusterStore.storedKubeConfigFolder, clusterId);
}
static embedCustomKubeConfig(clusterId: ClusterId, kubeConfig: KubeConfig | string): string {
@ -259,18 +267,18 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
}
@action
addCluster(model: ClusterModel | Cluster): Cluster {
addCluster(clusterOrModel: ClusterModel | Cluster): Cluster {
appEventBus.emit({ name: "cluster", action: "add" });
let cluster = model as Cluster;
if (!(model instanceof Cluster)) {
cluster = new Cluster(model);
}
const cluster = clusterOrModel instanceof Cluster
? clusterOrModel
: new Cluster(clusterOrModel);
if (!cluster.isManaged) {
cluster.enabled = true;
}
this.clusters.set(model.id, cluster);
this.clusters.set(cluster.id, cluster);
return cluster;
}

View File

@ -6,7 +6,7 @@ import yaml from "js-yaml";
import logger from "../main/logger";
import commandExists from "command-exists";
import { ExecValidationNotFoundError } from "./custom-errors";
import { newClusters, newContexts, newUsers } from "@kubernetes/client-node/dist/config_types";
import { Cluster, Context, newClusters, newContexts, newUsers, User } from "@kubernetes/client-node/dist/config_types";
export type KubeConfigValidationOpts = {
validateCluster?: boolean;
@ -28,11 +28,26 @@ function readResolvedPathSync(filePath: string): string {
return fse.readFileSync(path.resolve(resolveTilde(filePath)), "utf8");
}
function checkRawContext(rawContext: any): boolean {
return rawContext.name && rawContext.context?.cluster && rawContext.context?.user;
function checkRawCluster(rawCluster: any): boolean {
return Boolean(rawCluster?.name && rawCluster?.cluster?.server);
}
function loadToOptions(rawYaml: string): any {
function checkRawUser(rawUser: any): boolean {
return Boolean(rawUser?.name);
}
function checkRawContext(rawContext: any): boolean {
return Boolean(rawContext.name && rawContext.context?.cluster && rawContext.context?.user);
}
export interface KubeConfigOptions {
clusters: Cluster[];
users: User[];
contexts: Context[];
currentContext: string;
}
function loadToOptions(rawYaml: string): KubeConfigOptions {
const obj = yaml.safeLoad(rawYaml);
if (typeof obj !== "object" || !obj) {
@ -40,16 +55,14 @@ function loadToOptions(rawYaml: string): any {
}
const { clusters: rawClusters, users: rawUsers, contexts: rawContexts, "current-context": currentContext } = obj;
const clusters = newClusters(rawClusters);
const users = newUsers(rawUsers);
const clusters = newClusters(rawClusters?.filter(checkRawCluster));
const users = newUsers(rawUsers?.filter(checkRawUser));
const contexts = newContexts(rawContexts?.filter(checkRawContext));
return { clusters, users, contexts, currentContext };
}
export function loadConfig(pathOrContent?: string): KubeConfig {
const content = fse.pathExistsSync(pathOrContent) ? readResolvedPathSync(pathOrContent) : pathOrContent;
const options = loadToOptions(content);
export function loadFromOptions(options: KubeConfigOptions): KubeConfig {
const kc = new KubeConfig();
// need to load using the kubernetes client to generate a kubeconfig object
@ -58,6 +71,18 @@ export function loadConfig(pathOrContent?: string): KubeConfig {
return kc;
}
export function loadConfig(pathOrContent?: string): KubeConfig {
return loadConfigFromString(
fse.pathExistsSync(pathOrContent)
? readResolvedPathSync(pathOrContent)
: pathOrContent
);
}
export function loadConfigFromString(content: string): KubeConfig {
return loadFromOptions(loadToOptions(content));
}
/**
* KubeConfig is valid when there's at least one of each defined:
* - User
@ -181,42 +206,47 @@ export function getNodeWarningConditions(node: V1Node) {
/**
* Checks if `config` has valid `Context`, `User`, `Cluster`, and `exec` fields (if present when required)
*
* Note: This function returns an error instead of throwing it, returning `undefined` if the validation passes
*/
export function validateKubeConfig (config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}) {
// we only receive a single context, cluster & user object here so lets validate them as this
// will be called when we add a new cluster to Lens
export function validateKubeConfig(config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}): Error | undefined {
try {
// we only receive a single context, cluster & user object here so lets validate them as this
// will be called when we add a new cluster to Lens
const { validateUser = true, validateCluster = true, validateExec = true } = validationOpts;
const { validateUser = true, validateCluster = true, validateExec = true } = validationOpts;
const contextObject = config.getContextObject(contextName);
const contextObject = config.getContextObject(contextName);
// Validate the Context Object
if (!contextObject) {
throw new Error(`No valid context object provided in kubeconfig for context '${contextName}'`);
}
// Validate the Cluster Object
if (validateCluster && !config.getCluster(contextObject.cluster)) {
throw new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`);
}
const user = config.getUser(contextObject.user);
// Validate the User Object
if (validateUser && !user) {
throw new Error(`No valid user object provided in kubeconfig for context '${contextName}'`);
}
// Validate exec command if present
if (validateExec && 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
if (!commandExists.sync(execCommand)) {
logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${contextName} not found`);
throw new ExecValidationNotFoundError(execCommand, isAbsolute);
// Validate the Context Object
if (!contextObject) {
return new Error(`No valid context object provided in kubeconfig for context '${contextName}'`);
}
// Validate the Cluster Object
if (validateCluster && !config.getCluster(contextObject.cluster)) {
return new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`);
}
const user = config.getUser(contextObject.user);
// Validate the User Object
if (validateUser && !user) {
return new Error(`No valid user object provided in kubeconfig for context '${contextName}'`);
}
// Validate exec command if present
if (validateExec && 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
if (!commandExists.sync(execCommand)) {
return new ExecValidationNotFoundError(execCommand, isAbsolute);
}
}
} catch (error) {
return error;
}
}

View File

@ -11,6 +11,7 @@ import { kubeConfigDefaultPath, loadConfig } from "./kube-helpers";
import { appEventBus } from "./event-bus";
import logger from "../main/logger";
import path from "path";
import os from "os";
import { fileNameMigration } from "../migrations/user-store";
import { ObservableToggleSet } from "../renderer/utils";
@ -21,6 +22,12 @@ export interface UserStoreModel {
preferences: UserPreferencesModel;
}
export interface KubeconfigSyncEntry extends KubeconfigSyncValue {
filePath: string;
}
export interface KubeconfigSyncValue {}
export interface UserPreferencesModel {
httpsProxy?: string;
shell?: string;
@ -34,6 +41,7 @@ export interface UserPreferencesModel {
kubectlBinariesPath?: string;
openAtLogin?: boolean;
hiddenTableColumns?: [string, string[]][];
syncKubeconfigEntries?: KubeconfigSyncEntry[];
}
export class UserStore extends BaseStore<UserStoreModel> {
@ -44,8 +52,6 @@ export class UserStore extends BaseStore<UserStoreModel> {
configName: "lens-user-store",
migrations,
});
this.handleOnLoad();
}
@observable lastSeenAppVersion = "0.0.0";
@ -71,14 +77,31 @@ export class UserStore extends BaseStore<UserStoreModel> {
*/
@observable downloadKubectlBinaries = true;
@observable openAtLogin = false;
/**
* The column IDs under each configurable table ID that have been configured
* to not be shown
*/
hiddenTableColumns = observable.map<string, ObservableToggleSet<string>>();
protected async handleOnLoad() {
await this.whenLoaded;
/**
* The set of file/folder paths to be synced
*/
syncKubeconfigEntries = observable.map<string, KubeconfigSyncValue>([
[path.join(os.homedir(), ".kube"), {}]
]);
async load(): Promise<void> {
/**
* This has to be here before the call to `new Config` in `super.load()`
* as we have to make sure that file is in the expected place for that call
*/
await fileNameMigration();
await super.load();
// refresh new contexts
this.refreshNewContexts();
reaction(() => this.kubeConfigPath, this.refreshNewContexts);
await this.refreshNewContexts();
reaction(() => this.kubeConfigPath, () => this.refreshNewContexts());
if (app) {
// track telemetry availability
@ -99,16 +122,6 @@ export class UserStore extends BaseStore<UserStoreModel> {
}
}
async load(): Promise<void> {
/**
* This has to be here before the call to `new Config` in `super.load()`
* as we have to make sure that file is in the expected place for that call
*/
await fileNameMigration();
return super.load();
}
@computed get isNewVersion() {
return semver.gt(getAppVersion(), this.lastSeenAppVersion);
}
@ -171,7 +184,7 @@ export class UserStore extends BaseStore<UserStoreModel> {
this.localeTimezone = tz;
}
protected refreshNewContexts = async () => {
protected async refreshNewContexts() {
try {
const kubeConfig = await readFile(this.kubeConfigPath, "utf8");
@ -186,7 +199,7 @@ export class UserStore extends BaseStore<UserStoreModel> {
logger.error(err);
this.resetKubeConfigPath();
}
};
}
@action
markNewContextsAsSeen() {
@ -225,37 +238,50 @@ export class UserStore extends BaseStore<UserStoreModel> {
this.kubectlBinariesPath = preferences.kubectlBinariesPath;
this.openAtLogin = preferences.openAtLogin;
this.hiddenTableColumns.clear();
if (preferences.hiddenTableColumns) {
this.hiddenTableColumns.replace(
preferences.hiddenTableColumns
.map(([tableId, columnIds]) => [tableId, new ObservableToggleSet(columnIds)])
);
}
for (const [tableId, columnIds] of preferences.hiddenTableColumns ?? []) {
this.hiddenTableColumns.set(tableId, new ObservableToggleSet(columnIds));
if (preferences.syncKubeconfigEntries) {
this.syncKubeconfigEntries.replace(
preferences.syncKubeconfigEntries.map(({ filePath, ...rest }) => [filePath, rest])
);
}
}
toJSON(): UserStoreModel {
const hiddenTableColumns: [string, string[]][] = [];
const syncKubeconfigEntries: KubeconfigSyncEntry[] = [];
for (const [key, values] of this.hiddenTableColumns.entries()) {
hiddenTableColumns.push([key, Array.from(values)]);
}
for (const [filePath, rest] of this.syncKubeconfigEntries) {
syncKubeconfigEntries.push({ filePath, ...rest });
}
const model: UserStoreModel = {
kubeConfigPath: this.kubeConfigPath,
lastSeenAppVersion: this.lastSeenAppVersion,
seenContexts: Array.from(this.seenContexts),
preferences: {
httpsProxy: this.httpsProxy,
shell: this.shell,
colorTheme: this.colorTheme,
localeTimezone: this.localeTimezone,
allowUntrustedCAs: this.allowUntrustedCAs,
allowTelemetry: this.allowTelemetry,
downloadMirror: this.downloadMirror,
downloadKubectlBinaries: this.downloadKubectlBinaries,
downloadBinariesPath: this.downloadBinariesPath,
kubectlBinariesPath: this.kubectlBinariesPath,
openAtLogin: this.openAtLogin,
httpsProxy: toJS(this.httpsProxy),
shell: toJS(this.shell),
colorTheme: toJS(this.colorTheme),
localeTimezone: toJS(this.localeTimezone),
allowUntrustedCAs: toJS(this.allowUntrustedCAs),
allowTelemetry: toJS(this.allowTelemetry),
downloadMirror: toJS(this.downloadMirror),
downloadKubectlBinaries: toJS(this.downloadKubectlBinaries),
downloadBinariesPath: toJS(this.downloadBinariesPath),
kubectlBinariesPath: toJS(this.kubectlBinariesPath),
openAtLogin: toJS(this.openAtLogin),
hiddenTableColumns,
syncKubeconfigEntries,
},
};

View File

@ -0,0 +1,64 @@
import { action, IEnhancer, IObservableMapInitialValues, ObservableMap } from "mobx";
export class ExtendedMap<K, V> extends Map<K, V> {
constructor(protected getDefault: () => V, entries?: readonly (readonly [K, V])[] | null) {
super(entries);
}
getOrInsert(key: K, val: V): V {
if (this.has(key)) {
return this.get(key);
}
return this.set(key, val).get(key);
}
getOrInsertWith(key: K, getVal: () => V): V {
if (this.has(key)) {
return this.get(key);
}
return this.set(key, getVal()).get(key);
}
getOrDefault(key: K): V {
if (this.has(key)) {
return this.get(key);
}
return this.set(key, this.getDefault()).get(key);
}
}
export class ExtendedObservableMap<K, V> extends ObservableMap<K, V> {
constructor(protected getDefault: () => V, initialData?: IObservableMapInitialValues<K, V>, enhancer?: IEnhancer<V>, name?: string) {
super(initialData, enhancer, name);
}
@action
getOrInsert(key: K, val: V): V {
if (this.has(key)) {
return this.get(key);
}
return this.set(key, val).get(key);
}
@action
getOrInsertWith(key: K, getVal: () => V): V {
if (this.has(key)) {
return this.get(key);
}
return this.set(key, getVal()).get(key);
}
@action
getOrDefault(key: K): V {
if (this.has(key)) {
return this.get(key);
}
return this.set(key, this.getDefault()).get(key);
}
}

View File

@ -14,6 +14,7 @@ export * from "./disposer";
export * from "./disposer";
export * from "./downloadFile";
export * from "./escapeRegExp";
export * from "./extended-map";
export * from "./getRandId";
export * from "./openExternal";
export * from "./reject-promise";

View File

@ -23,3 +23,67 @@ export function* take<T>(src: Iterable<T>, n: number): Iterable<T> {
break outer;
}
}
/**
* Creates a new iterator that iterates (lazily) over its input and yields the
* result of `fn` for each item.
* @param src A type that can be iterated over
* @param fn The function that is called for each value
*/
export function* map<T, U>(src: Iterable<T>, fn: (from: T) => U): Iterable<U> {
for (const from of src) {
yield fn(from);
}
}
export function* flatMap<T, U>(src: Iterable<T>, fn: (from: T) => Iterable<U>): Iterable<U> {
for (const from of src) {
yield* fn(from);
}
}
/**
* Creates a new iterator that iterates (lazily) over its input and yields the
* items that return a `truthy` value from `fn`.
* @param src A type that can be iterated over
* @param fn The function that is called for each value
*/
export function* filter<T>(src: Iterable<T>, fn: (from: T) => any): Iterable<T> {
for (const from of src) {
if (fn(from)) {
yield from;
}
}
}
/**
* Creates a new iterator that iterates (lazily) over its input and yields the
* result of `fn` when it is `truthy`
* @param src A type that can be iterated over
* @param fn The function that is called for each value
*/
export function* filterMap<T, U>(src: Iterable<T>, fn: (from: T) => U): Iterable<U> {
for (const from of src) {
const res = fn(from);
if (res) {
yield res;
}
}
}
/**
* Creates a new iterator that iterates (lazily) over its input and yields the
* result of `fn` when it is not null or undefined
* @param src A type that can be iterated over
* @param fn The function that is called for each value
*/
export function* filterMapStrict<T, U>(src: Iterable<T>, fn: (from: T) => U): Iterable<U> {
for (const from of src) {
const res = fn(from);
if (res != null) {
yield res;
}
}
}

View File

@ -20,7 +20,7 @@ export class LensMainExtension extends LensExtension {
}
addCatalogSource(id: string, source: IObservableArray<CatalogEntity>) {
catalogEntityRegistry.addSource(`${this.name}:${id}`, source);
catalogEntityRegistry.addObservableSource(`${this.name}:${id}`, source);
}
removeCatalogSource(id: string) {

View File

@ -1,7 +1,8 @@
import { autorun, toJS } from "mobx";
import { reaction, toJS } from "mobx";
import { broadcastMessage, subscribeToBroadcast, unsubscribeFromBroadcast } from "../common/ipc";
import { CatalogEntityRegistry} from "../common/catalog";
import "../common/catalog-entities/kubernetes-cluster";
import { Disposer } from "../common/utils";
export class CatalogPusher {
static init(catalog: CatalogEntityRegistry) {
@ -11,22 +12,20 @@ export class CatalogPusher {
private constructor(private catalog: CatalogEntityRegistry) {}
init() {
const disposers: { (): void; }[] = [];
const disposers: Disposer[] = [];
disposers.push(autorun(() => {
this.broadcast();
disposers.push(reaction(() => this.catalog.items, (items) => {
broadcastMessage("catalog:items", toJS(items, { recurseEverything: true }));
}, {
fireImmediately: true,
}));
const listener = subscribeToBroadcast("catalog:broadcast", () => {
this.broadcast();
broadcastMessage("catalog:items", toJS(this.catalog.items, { recurseEverything: true }));
});
disposers.push(() => unsubscribeFromBroadcast("catalog:broadcast", listener));
return disposers;
}
broadcast() {
broadcastMessage("catalog:items", toJS(this.catalog.items, { recurseEverything: true }));
}
}

View File

@ -0,0 +1,252 @@
import { ObservableMap } from "mobx";
import { CatalogEntity } from "../../../common/catalog";
import { loadFromOptions } from "../../../common/kube-helpers";
import { Cluster } from "../../cluster";
import { computeDiff, configToModels } from "../kubeconfig-sync";
import mockFs from "mock-fs";
import fs from "fs";
describe("kubeconfig-sync.source tests", () => {
beforeEach(() => {
mockFs();
});
afterEach(() => {
mockFs.restore();
});
describe("configsToModels", () => {
it("should filter out invalid split configs", () => {
const config = loadFromOptions({
clusters: [],
users: [],
contexts: [],
currentContext: "foobar"
});
expect(configToModels(config, "").length).toBe(0);
});
it("should keep a single valid split config", () => {
const config = loadFromOptions({
clusters: [{
name: "cluster-name",
server: "1.2.3.4",
skipTLSVerify: false,
}],
users: [{
name: "user-name",
}],
contexts: [{
cluster: "cluster-name",
name: "context-name",
user: "user-name",
}],
currentContext: "foobar"
});
const models = configToModels(config, "/bar");
expect(models.length).toBe(1);
expect(models[0].contextName).toBe("context-name");
expect(models[0].kubeConfigPath).toBe("/bar");
});
});
describe("computeDiff", () => {
it("should leave an empty source empty if there are no entries", () => {
const contents = "";
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
const port = 0;
const filePath = "/bar";
computeDiff(contents, rootSource, port, filePath);
expect(rootSource.size).toBe(0);
});
it("should add only the valid clusters to the source", () => {
const contents = JSON.stringify({
clusters: [{
name: "cluster-name",
cluster: {
server: "1.2.3.4",
},
skipTLSVerify: false,
}],
users: [{
name: "user-name",
}],
contexts: [{
name: "context-name",
context: {
cluster: "cluster-name",
user: "user-name",
},
}, {
name: "context-the-second",
context: {
cluster: "missing-cluster",
user: "user-name",
},
}],
currentContext: "foobar"
});
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
const port = 0;
const filePath = "/bar";
fs.writeFileSync(filePath, contents);
computeDiff(contents, rootSource, port, filePath);
expect(rootSource.size).toBe(1);
const c = rootSource.values().next().value[0] as Cluster;
expect(c.kubeConfigPath).toBe("/bar");
expect(c.contextName).toBe("context-name");
});
it("should remove a cluster when it is removed from the contents", () => {
const contents = JSON.stringify({
clusters: [{
name: "cluster-name",
cluster: {
server: "1.2.3.4",
},
skipTLSVerify: false,
}],
users: [{
name: "user-name",
}],
contexts: [{
name: "context-name",
context: {
cluster: "cluster-name",
user: "user-name",
},
}, {
name: "context-the-second",
context: {
cluster: "missing-cluster",
user: "user-name",
},
}],
currentContext: "foobar"
});
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
const port = 0;
const filePath = "/bar";
fs.writeFileSync(filePath, contents);
computeDiff(contents, rootSource, port, filePath);
expect(rootSource.size).toBe(1);
const c = rootSource.values().next().value[0] as Cluster;
expect(c.kubeConfigPath).toBe("/bar");
expect(c.contextName).toBe("context-name");
computeDiff("{}", rootSource, port, filePath);
expect(rootSource.size).toBe(0);
});
it("should remove only the cluster that it is removed from the contents", () => {
const contents = JSON.stringify({
clusters: [{
name: "cluster-name",
cluster: {
server: "1.2.3.4",
},
skipTLSVerify: false,
}],
users: [{
name: "user-name",
}, {
name: "user-name-2",
}],
contexts: [{
name: "context-name",
context: {
cluster: "cluster-name",
user: "user-name",
},
}, {
name: "context-name-2",
context: {
cluster: "cluster-name",
user: "user-name-2",
},
}, {
name: "context-the-second",
context: {
cluster: "missing-cluster",
user: "user-name",
},
}],
currentContext: "foobar"
});
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
const port = 0;
const filePath = "/bar";
fs.writeFileSync(filePath, contents);
computeDiff(contents, rootSource, port, filePath);
expect(rootSource.size).toBe(2);
{
const c = rootSource.values().next().value[0] as Cluster;
expect(c.kubeConfigPath).toBe("/bar");
expect(["context-name", "context-name-2"].includes(c.contextName)).toBe(true);
}
const newContents = JSON.stringify({
clusters: [{
name: "cluster-name",
cluster: {
server: "1.2.3.4",
},
skipTLSVerify: false,
}],
users: [{
name: "user-name",
}, {
name: "user-name-2",
}],
contexts: [{
name: "context-name",
context: {
cluster: "cluster-name",
user: "user-name",
},
}, {
name: "context-the-second",
context: {
cluster: "missing-cluster",
user: "user-name",
},
}],
currentContext: "foobar"
});
computeDiff(newContents, rootSource, port, filePath);
expect(rootSource.size).toBe(1);
{
const c = rootSource.values().next().value[0] as Cluster;
expect(c.kubeConfigPath).toBe("/bar");
expect(c.contextName).toBe("context-name");
}
});
});
});

View File

@ -0,0 +1 @@
export { KubeconfigSyncManager } from "./kubeconfig-sync";

View File

@ -0,0 +1,251 @@
import { action, observable, IComputedValue, computed, ObservableMap, runInAction } from "mobx";
import { CatalogEntity, catalogEntityRegistry } from "../../common/catalog";
import { watch } from "chokidar";
import fs from "fs";
import fse from "fs-extra";
import * as uuid from "uuid";
import stream from "stream";
import { Disposer, ExtendedObservableMap, iter, Singleton } from "../../common/utils";
import logger from "../logger";
import { KubeConfig } from "@kubernetes/client-node";
import { loadConfigFromString, splitConfig, validateKubeConfig } from "../../common/kube-helpers";
import { Cluster } from "../cluster";
import { catalogEntityFromCluster } from "../cluster-manager";
import { UserStore } from "../../common/user-store";
import { ClusterStore, UpdateClusterModel } from "../../common/cluster-store";
const logPrefix = "[KUBECONFIG-SYNC]:";
export class KubeconfigSyncManager extends Singleton {
protected sources = observable.map<string, [IComputedValue<CatalogEntity[]>, Disposer]>();
protected syncing = false;
protected syncListDisposer?: Disposer;
protected static readonly syncName = "lens:kube-sync";
@action
startSync(port: number): void {
if (this.syncing) {
return;
}
this.syncing = true;
logger.info(`${logPrefix} starting requested syncs`);
catalogEntityRegistry.addComputedSource(KubeconfigSyncManager.syncName, computed(() => (
Array.from(iter.flatMap(
this.sources.values(),
([entities]) => entities.get()
))
)));
// This must be done so that c&p-ed clusters are visible
this.startNewSync(ClusterStore.storedKubeConfigFolder, port);
for (const filePath of UserStore.getInstance().syncKubeconfigEntries.keys()) {
this.startNewSync(filePath, port);
}
this.syncListDisposer = UserStore.getInstance().syncKubeconfigEntries.observe(change => {
switch (change.type) {
case "add":
this.startNewSync(change.name, port);
break;
case "delete":
this.stopOldSync(change.name);
break;
}
});
}
@action
stopSync() {
this.syncListDisposer?.();
for (const filePath of this.sources.keys()) {
this.stopOldSync(filePath);
}
catalogEntityRegistry.removeSource(KubeconfigSyncManager.syncName);
this.syncing = false;
}
@action
protected async startNewSync(filePath: string, port: number): Promise<void> {
if (this.sources.has(filePath)) {
// don't start a new sync if we already have one
return void logger.debug(`${logPrefix} already syncing file/folder`, { filePath });
}
try {
this.sources.set(filePath, await watchFileChanges(filePath, port));
logger.info(`${logPrefix} starting sync of file/folder`, { filePath });
logger.debug(`${logPrefix} ${this.sources.size} files/folders watched`, { files: Array.from(this.sources.keys()) });
} catch (error) {
logger.warn(`${logPrefix} failed to start watching changes: ${error}`);
}
}
@action
protected stopOldSync(filePath: string): void {
if (!this.sources.delete(filePath)) {
// already stopped
return void logger.debug(`${logPrefix} no syncing file/folder to stop`, { filePath });
}
logger.info(`${logPrefix} stopping sync of file/folder`, { filePath });
logger.debug(`${logPrefix} ${this.sources.size} files/folders watched`, { files: Array.from(this.sources.keys()) });
}
}
// exported for testing
export function configToModels(config: KubeConfig, filePath: string): UpdateClusterModel[] {
const validConfigs = [];
for (const contextConfig of splitConfig(config)) {
const error = validateKubeConfig(contextConfig, contextConfig.currentContext);
if (error) {
logger.debug(`${logPrefix} context failed validation: ${error}`, { context: contextConfig.currentContext, filePath });
} else {
validConfigs.push({
kubeConfigPath: filePath,
contextName: contextConfig.currentContext,
});
}
}
return validConfigs;
}
type RootSourceValue = [Cluster, CatalogEntity];
type RootSource = ObservableMap<string, RootSourceValue>;
// exported for testing
export function computeDiff(contents: string, source: RootSource, port: number, filePath: string): void {
runInAction(() => {
try {
const rawModels = configToModels(loadConfigFromString(contents), filePath);
const models = new Map(rawModels.map(m => [m.contextName, m]));
logger.debug(`${logPrefix} File now has ${models.size} entries`, { filePath });
for (const [contextName, value] of source) {
const model = models.get(contextName);
// remove and disconnect clusters that were removed from the config
if (!model) {
value[0].disconnect();
source.delete(contextName);
logger.debug(`${logPrefix} Removed old cluster from sync`, { filePath, contextName });
continue;
}
// TODO: For the update check we need to make sure that the config itself hasn't changed.
// Probably should make it so that cluster keeps a copy of the config in its memory and
// diff against that
// or update the model and mark it as not needed to be added
value[0].updateModel(model);
models.delete(contextName);
logger.debug(`${logPrefix} Updated old cluster from sync`, { filePath, contextName });
}
for (const [contextName, model] of models) {
// add new clusters to the source
try {
const cluster = new Cluster({ ...model, id: uuid.v4() });
if (!cluster.apiUrl) {
throw new Error("Cluster constructor failed, see above error");
}
cluster.init(port);
const entity = catalogEntityFromCluster(cluster);
entity.metadata.labels.file = filePath;
source.set(contextName, [cluster, entity]);
logger.debug(`${logPrefix} Added new cluster from sync`, { filePath, contextName });
} catch (error) {
logger.warn(`${logPrefix} Failed to create cluster from model: ${error}`, { filePath, contextName });
}
}
} catch (error) {
logger.warn(`${logPrefix} Failed to compute diff: ${error}`, { filePath });
source.clear(); // clear source if we have failed so as to not show outdated information
}
});
}
function diffChangedConfig(filePath: string, source: RootSource, port: number): Disposer {
logger.debug(`${logPrefix} file changed`, { filePath });
// TODO: replace with an AbortController with fs.readFile when we upgrade to Node 16 (after it comes out)
const fileReader = fs.createReadStream(filePath, {
mode: fs.constants.O_RDONLY,
});
const readStream: stream.Readable = fileReader;
const bufs: Buffer[] = [];
let closed = false;
const cleanup = () => {
closed = true;
fileReader.close(); // This may not close the stream.
// Artificially marking end-of-stream, as if the underlying resource had
// indicated end-of-file by itself, allows the stream to close.
// This does not cancel pending read operations, and if there is such an
// operation, the process may still not be able to exit successfully
// until it finishes.
fileReader.push(null);
fileReader.read(0);
readStream.removeAllListeners();
};
readStream
.on("data", chunk => bufs.push(chunk))
.on("close", () => cleanup())
.on("error", error => {
cleanup();
logger.warn(`${logPrefix} failed to read file: ${error}`, { filePath });
})
.on("end", () => {
if (!closed) {
computeDiff(Buffer.concat(bufs).toString("utf-8"), source, port, filePath);
}
});
return cleanup;
}
async function watchFileChanges(filePath: string, port: number): Promise<[IComputedValue<CatalogEntity[]>, Disposer]> {
const stat = await fse.stat(filePath); // traverses symlinks, is a race condition
const watcher = watch(filePath, {
followSymlinks: true,
depth: stat.isDirectory() ? 0 : 1, // DIRs works with 0 but files need 1 (bug: https://github.com/paulmillr/chokidar/issues/1095)
disableGlobbing: true,
});
const rootSource = new ExtendedObservableMap<string, ObservableMap<string, RootSourceValue>>(observable.map);
const derivedSource = computed(() => Array.from(iter.flatMap(rootSource.values(), from => iter.map(from.values(), child => child[1]))));
const stoppers = new Map<string, Disposer>();
watcher
.on("change", (childFilePath) => {
stoppers.get(childFilePath)();
stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrDefault(childFilePath), port));
})
.on("add", (childFilePath) => {
stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrDefault(childFilePath), port));
})
.on("unlink", (childFilePath) => {
stoppers.get(childFilePath)();
stoppers.delete(childFilePath);
rootSource.delete(childFilePath);
})
.on("error", error => logger.error(`${logPrefix} watching file/folder failed: ${error}`, { filePath }));
return [derivedSource, () => watcher.close()];
}

View File

@ -18,7 +18,7 @@ export class ClusterManager extends Singleton {
constructor(public readonly port: number) {
super();
catalogEntityRegistry.addSource("lens:kubernetes-clusters", this.catalogSource);
catalogEntityRegistry.addObservableSource("lens:kubernetes-clusters", this.catalogSource);
// auto-init clusters
reaction(() => ClusterStore.getInstance().enabledClustersList, (clusters) => {
clusters.forEach((cluster) => {
@ -59,17 +59,17 @@ export class ClusterManager extends Singleton {
}
@action protected updateCatalogSource(clusters: Cluster[]) {
this.catalogSource.forEach((entity, index) => {
const clusterIndex = clusters.findIndex((cluster) => entity.metadata.uid === cluster.id);
this.catalogSource.replace(this.catalogSource.filter(entity => (
clusters.find((cluster) => entity.metadata.uid === cluster.id)
)));
if (clusterIndex === -1) {
this.catalogSource.splice(index, 1);
for (const cluster of clusters) {
if (cluster.ownerRef) {
continue;
}
});
clusters.filter((c) => !c.ownerRef).forEach((cluster) => {
const entityIndex = this.catalogSource.findIndex((entity) => entity.metadata.uid === cluster.id);
const newEntity = this.catalogEntityFromCluster(cluster);
const newEntity = catalogEntityFromCluster(cluster);
if (entityIndex === -1) {
this.catalogSource.push(newEntity);
@ -84,11 +84,15 @@ export class ClusterManager extends Singleton {
};
this.catalogSource.splice(entityIndex, 1, newEntity);
}
});
}
}
@action syncClustersFromCatalog(entities: KubernetesCluster[]) {
entities.filter((entity) => entity.metadata.source !== "local").forEach((entity: KubernetesCluster) => {
for (const entity of entities) {
if (entity.metadata.source !== "local") {
continue;
}
const cluster = ClusterStore.getInstance().getById(entity.metadata.uid);
if (!cluster) {
@ -104,7 +108,7 @@ export class ClusterManager extends Singleton {
});
} else {
cluster.enabled = true;
if (!cluster.ownerRef) cluster.ownerRef = clusterOwnerRef;
cluster.ownerRef ||= clusterOwnerRef;
cluster.preferences.clusterName = entity.metadata.name;
cluster.kubeConfigPath = entity.spec.kubeconfigPath;
cluster.contextName = entity.spec.kubeconfigContext;
@ -114,32 +118,7 @@ export class ClusterManager extends Singleton {
active: !cluster.disconnected
};
}
});
}
protected catalogEntityFromCluster(cluster: Cluster) {
return new KubernetesCluster(toJS({
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "KubernetesCluster",
metadata: {
uid: cluster.id,
name: cluster.name,
source: "local",
labels: {
"distro": (cluster.metadata["distribution"] || "unknown").toString()
}
},
spec: {
kubeconfigPath: cluster.kubeConfigPath,
kubeconfigContext: cluster.contextName
},
status: {
phase: cluster.disconnected ? "disconnected" : "connected",
reason: "",
message: "",
active: !cluster.disconnected
}
}));
}
}
protected onNetworkOffline() {
@ -192,3 +171,28 @@ export class ClusterManager extends Singleton {
return cluster;
}
}
export function catalogEntityFromCluster(cluster: Cluster) {
return new KubernetesCluster(toJS({
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "KubernetesCluster",
metadata: {
uid: cluster.id,
name: cluster.name,
source: "local",
labels: {
distro: cluster.distribution,
}
},
spec: {
kubeconfigPath: cluster.kubeConfigPath,
kubeconfigContext: cluster.contextName
},
status: {
phase: cluster.disconnected ? "disconnected" : "connected",
reason: "",
message: "",
active: !cluster.disconnected
}
}));
}

View File

@ -1,5 +1,5 @@
import { ipcMain } from "electron";
import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences } from "../common/cluster-store";
import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel } from "../common/cluster-store";
import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api";
import { action, comparer, computed, observable, reaction, toJS, when } from "mobx";
import { apiKubePrefix } from "../common/vars";
@ -57,7 +57,7 @@ export interface ClusterState {
*/
export class Cluster implements ClusterModel, ClusterState {
/** Unique id for a cluster */
public id: ClusterId;
public readonly id: ClusterId;
/**
* Kubectl
*
@ -85,7 +85,7 @@ export class Cluster implements ClusterModel, ClusterState {
whenReady = when(() => this.ready);
/**
* Is cluster object initializinng on-going
* Is cluster object initializing on-going
*
* @observable
*/
@ -231,6 +231,10 @@ export class Cluster implements ClusterModel, ClusterState {
return this.preferences.clusterName || this.contextName;
}
@computed get distribution(): string {
return this.metadata.distribution?.toString() || "unknown";
}
/**
* Prometheus preferences
*
@ -253,12 +257,17 @@ export class Cluster implements ClusterModel, ClusterState {
}
constructor(model: ClusterModel) {
this.id = model.id;
this.updateModel(model);
try {
const kubeconfig = this.getKubeconfig();
const error = validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false});
if (error) {
throw error;
}
validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false});
this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server;
} catch(err) {
logger.error(err);
@ -279,8 +288,34 @@ export class Cluster implements ClusterModel, ClusterState {
*
* @param model
*/
@action updateModel(model: ClusterModel) {
Object.assign(this, model);
@action updateModel(model: UpdateClusterModel) {
// Note: do not assign ID as that should never be updated
this.kubeConfigPath = model.kubeConfigPath;
if (model.workspace) {
this.workspace = model.workspace;
}
if (model.contextName) {
this.contextName = model.contextName;
}
if (model.preferences) {
this.preferences = model.preferences;
}
if (model.metadata) {
this.metadata = model.metadata;
}
if (model.ownerRef) {
this.ownerRef = model.ownerRef;
}
if (model.accessibleNamespaces) {
this.accessibleNamespaces = model.accessibleNamespaces;
}
}
/**

View File

@ -33,6 +33,7 @@ import { CatalogPusher } from "./catalog-pusher";
import { catalogEntityRegistry } from "../common/catalog";
import { HotbarStore } from "../common/hotbar-store";
import { HelmRepoManager } from "./helm/helm-repo-manager";
import { KubeconfigSyncManager } from "./catalog-sources";
const workingDir = path.join(app.getPath("appData"), appName);
@ -131,6 +132,9 @@ app.on("ready", async () => {
const clusterManager = ClusterManager.getInstance();
// create kubeconfig sync manager
KubeconfigSyncManager.createInstance().startSync(clusterManager.port);
// run proxy
try {
logger.info("🔌 Starting LensProxy");
@ -237,6 +241,7 @@ app.on("will-quit", (event) => {
logger.info("APP:QUIT");
appEventBus.emit({name: "app", action: "close"});
ClusterManager.getInstance(false)?.stop(); // close cluster connections
KubeconfigSyncManager.getInstance(false)?.stopSync();
if (blockQuit) {
event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.)

View File

@ -43,27 +43,6 @@ describe("CatalogEntityRegistry", () => {
expect(catalog.items.length).toEqual(2);
});
it("ignores unknown items", () => {
const catalog = new CatalogEntityRegistry(catalogCategoryRegistry);
const items = [{
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "FooBar",
metadata: {
uid: "123",
name: "foobar",
source: "test",
labels: {}
},
status: {
phase: "disconnected"
},
spec: {}
}];
catalog.updateItems(items);
expect(catalog.items.length).toEqual(0);
});
it("updates existing items", () => {
const catalog = new CatalogEntityRegistry(catalogCategoryRegistry);
const items = [{

View File

@ -17,27 +17,7 @@ export class CatalogEntityRegistry {
}
@action updateItems(items: (CatalogEntityData & CatalogEntityKindData)[]) {
this._items.forEach((item, index) => {
const foundIndex = items.findIndex((i) => i.apiVersion === item.apiVersion && i.kind === item.kind && i.metadata.uid === item.metadata.uid);
if (foundIndex === -1) {
this._items.splice(index, 1);
}
});
items.forEach((data) => {
const item = this.categoryRegistry.getEntityForData(data);
if (!item) return; // invalid data
const index = this._items.findIndex((i) => i.apiVersion === item.apiVersion && i.kind === item.kind && i.metadata.uid === item.metadata.uid);
if (index === -1) {
this._items.push(item);
} else {
this._items.splice(index, 1, item);
}
});
this._items = items.map(data => this.categoryRegistry.getEntityForData(data));
}
set activeEntity(entity: CatalogEntity) {

View File

@ -21,6 +21,7 @@ import { LensApp } from "./lens-app";
import { ThemeStore } from "./theme.store";
import { HelmRepoManager } from "../main/helm/helm-repo-manager";
import { ExtensionInstallationStateStore } from "./components/+extensions/extension-install.store";
import { DefaultProps } from "./mui-base-theme";
/**
* If this is a development buid, wait a second to attach
@ -92,7 +93,7 @@ export async function bootstrap(App: AppComponent) {
});
render(<>
{isMac && <div id="draggable-top" />}
<App />
{DefaultProps(App)}
</>, rootElem);
}

View File

@ -11,7 +11,7 @@ import { AceEditor } from "../ace-editor";
import { Button } from "../button";
import { Icon } from "../icon";
import { kubeConfigDefaultPath, loadConfig, splitConfig, validateConfig, validateKubeConfig } from "../../../common/kube-helpers";
import { ClusterModel, ClusterStore } from "../../../common/cluster-store";
import { ClusterStore } from "../../../common/cluster-store";
import { v4 as uuid } from "uuid";
import { navigate } from "../../navigation";
import { UserStore } from "../../../common/user-store";
@ -132,36 +132,28 @@ export class AddCluster extends React.Component {
};
@action
addClusters = () => {
let newClusters: ClusterModel[] = [];
addClusters = (): void => {
try {
if (!this.selectedContexts.length) {
this.error = "Please select at least one cluster context";
return;
return void (this.error = "Please select at least one cluster context");
}
this.error = "";
this.isWaiting = true;
appEventBus.emit({ name: "cluster-add", action: "click" });
newClusters = this.selectedContexts.filter(context => {
try {
const kubeConfig = this.kubeContexts.get(context);
const newClusters = this.selectedContexts.filter(context => {
const kubeConfig = this.kubeContexts.get(context);
const error = validateKubeConfig(kubeConfig, context);
validateKubeConfig(kubeConfig, context);
if (error) {
this.error = error.toString();
return true;
} catch (err) {
this.error = String(err.message);
if (err instanceof ExecValidationNotFoundError) {
if (error instanceof ExecValidationNotFoundError) {
Notifications.error(<>Error while adding cluster(s): {this.error}</>);
return false;
} else {
throw new Error(err);
}
}
return Boolean(!error);
}).map(context => {
const clusterId = uuid();
const kubeConfig = this.kubeContexts.get(context);

View File

@ -57,4 +57,23 @@
color: var(--halfGray);
}
}
.TableCell.labels {
overflow-x: scroll;
text-overflow: unset;
&::-webkit-scrollbar {
display: none;
}
.Badge {
overflow: unset;
text-overflow: unset;
max-width: unset;
&:not(:first-child) {
margin-left: 0.5em;
}
}
}
}

View File

@ -0,0 +1,176 @@
import React from "react";
import { remote } from "electron";
import { Avatar, IconButton, List, ListItem, ListItemAvatar, ListItemSecondaryAction, ListItemText, Paper } from "@material-ui/core";
import { Description, Folder, Delete, HelpOutline } from "@material-ui/icons";
import { action, computed, observable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import fse from "fs-extra";
import { KubeconfigSyncEntry, KubeconfigSyncValue, UserStore } from "../../../common/user-store";
import { Button } from "../button";
import { SubTitle } from "../layout/sub-title";
import { Spinner } from "../spinner";
import logger from "../../../main/logger";
import { iter } from "../../utils";
interface SyncInfo {
type: "file" | "folder" | "unknown";
}
interface Entry extends Value {
filePath: string;
}
interface Value {
data: KubeconfigSyncValue;
info: SyncInfo;
}
async function getMapEntry({ filePath, ...data}: KubeconfigSyncEntry): Promise<[string, Value]> {
try {
// stat follows the stat(2) linux syscall spec, namely it follows symlinks
const stats = await fse.stat(filePath);
if (stats.isFile()) {
return [filePath, { info: { type: "file" }, data }];
}
if (stats.isDirectory()) {
return [filePath, { info: { type: "folder" }, data }];
}
logger.warn("[KubeconfigSyncs]: unknown stat entry", { stats });
return [filePath, { info: { type: "unknown" }, data }];
} catch (error) {
logger.warn(`[KubeconfigSyncs]: failed to stat entry: ${error}`, { error });
return [filePath, { info: { type: "unknown" }, data }];
}
}
@observer
export class KubeconfigSyncs extends React.Component {
syncs = observable.map<string, Value>();
@observable loaded = false;
async componentDidMount() {
const mapEntries = await Promise.all(
iter.map(
UserStore.getInstance().syncKubeconfigEntries,
([filePath, ...value]) => getMapEntry({ filePath, ...value }),
),
);
this.syncs.replace(mapEntries);
this.loaded = true;
disposeOnUnmount(this, [
reaction(() => Array.from(this.syncs.entries(), ([filePath, { data }]) => [filePath, data]), syncs => {
UserStore.getInstance().syncKubeconfigEntries.replace(syncs);
})
]);
}
@computed get syncsList(): Entry[] | undefined {
if (!this.loaded) {
return undefined;
}
return Array.from(this.syncs.entries(), ([filePath, value]) => ({ filePath, ...value }));
}
@action
openFileDialog = async () => {
const { dialog, BrowserWindow } = remote;
const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
properties: ["openFile", "showHiddenFiles", "multiSelections", "openDirectory"],
message: "Select kubeconfig file(s) and folder(s)",
buttonLabel: "Sync",
});
if (canceled) {
return;
}
const newEntries = await Promise.all(filePaths.map(filePath => getMapEntry({ filePath })));
for (const [filePath, info] of newEntries) {
this.syncs.set(filePath, info);
}
};
renderEntryIcon(entry: Entry) {
switch (entry.info.type) {
case "file":
return <Description />;
case "folder":
return <Folder />;
case "unknown":
return <HelpOutline />;
}
}
renderEntry = (entry: Entry) => {
return (
<Paper className="entry" key={entry.filePath} elevation={3}>
<ListItem>
<ListItemAvatar>
<Avatar>
{this.renderEntryIcon(entry)}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={entry.filePath}
className="description"
/>
<ListItemSecondaryAction className="action">
<IconButton
edge="end"
aria-label="delete"
onClick={() => this.syncs.delete(entry.filePath)}
>
<Delete />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</Paper>
);
};
renderEntries() {
const entries = this.syncsList;
if (!entries) {
return (
<div className="loading-spinner">
<Spinner />
</div>
);
}
return (
<List className="kubeconfig-sync-list">
{entries.map(this.renderEntry)}
</List>
);
}
render() {
return (
<>
<section className="small">
<SubTitle title="Files and Folders to sync" />
<Button
primary
label="Sync file or folder"
onClick={() => void this.openFileDialog()}
/>
<div className="hint">
Sync an individual file or all files in a folder (non-recursive).
</div>
{this.renderEntries()}
</section>
</>
);
}
}

View File

@ -1,2 +1,32 @@
.Preferences {
}
.loading-spinner {
margin: auto;
}
.kubeconfig-sync-list {
.entry {
&.MuiPaper-root {
background-color: var(--inputControlBackground);
margin-bottom: var(--flex-gap, 1em);
color: inherit;
}
.MuiAvatar-root {
color: var(--buttonPrimaryBackground);
font-size: calc(2.5 * var(--unit));
}
.description {
font-family: monospace;
.MuiTypography-body1 {
font-size: var(--font-size);
}
}
.action .MuiIconButton-root {
font-size: calc(2.5 * var(--unit));
}
}
}
}

View File

@ -18,6 +18,7 @@ import { KubectlBinaries } from "./kubectl-binaries";
import { navigation } from "../../navigation";
import { Tab, Tabs } from "../tabs";
import { FormSwitch, Switcher } from "../switch";
import { KubeconfigSyncs } from "./kubeconfig-syncs";
enum Pages {
Application = "application",
@ -209,7 +210,6 @@ export class Preferences extends React.Component {
</section>
</section>
)}
{this.activeTab == Pages.Kubernetes && (
<section id="kubernetes">
<section id="kubectl">
@ -217,20 +217,23 @@ export class Preferences extends React.Component {
<KubectlBinaries />
</section>
<hr/>
<section id="kube-sync">
<h2 data-testid="kubernetes-sync-header">Kubeconfig Syncs</h2>
<KubeconfigSyncs />
</section>
<hr/>
<section id="helm">
<h2>Helm Charts</h2>
<HelmCharts/>
</section>
</section>
)}
{this.activeTab == Pages.Telemetry && (
<section id="telemetry">
<h2 data-testid="telemetry-header">Telemetry</h2>
{telemetryExtensions.map(this.renderExtension)}
</section>
)}
{this.activeTab == Pages.Extensions && (
<section id="extensions">
<h2>Extensions</h2>

View File

@ -0,0 +1,34 @@
import React from "react";
import { createMuiTheme, ThemeProvider } from "@material-ui/core";
const defaultTheme = createMuiTheme({
props: {
MuiIconButton: {
color: "inherit",
},
MuiSvgIcon: {
fontSize: "inherit",
},
MuiTooltip: {
placement: "top",
}
},
overrides: {
MuiIconButton: {
root: {
"&:hover": {
color: "var(--iconActiveColor)",
backgroundColor: "var(--iconActiveBackground)",
}
}
}
},
});
export function DefaultProps(App: React.ComponentType) {
return (
<ThemeProvider theme= { defaultTheme } >
<App />
</ThemeProvider>
);
}

View File

@ -838,6 +838,13 @@
react-is "^16.8.0"
react-transition-group "^4.4.0"
"@material-ui/icons@^4.11.2":
version "4.11.2"
resolved "https://registry.yarnpkg.com/@material-ui/icons/-/icons-4.11.2.tgz#b3a7353266519cd743b6461ae9fdfcb1b25eb4c5"
integrity sha512-fQNsKX2TxBmqIGJCSi3tGTO/gZ+eJgWmMJkgDiOfyNaunNaxcklJQFaFogYcFl0qFuaEz1qaXYXboa/bUXVSOQ==
dependencies:
"@babel/runtime" "^7.4.4"
"@material-ui/lab@^4.0.0-alpha.57":
version "4.0.0-alpha.57"
resolved "https://registry.yarnpkg.com/@material-ui/lab/-/lab-4.0.0-alpha.57.tgz#e8961bcf6449e8a8dabe84f2700daacfcafbf83a"
@ -1364,13 +1371,13 @@
jest-diff "^25.2.1"
pretty-format "^25.2.1"
"@types/jest@^25.2.3":
version "25.2.3"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-25.2.3.tgz#33d27e4c4716caae4eced355097a47ad363fdcaf"
integrity sha512-JXc1nK/tXHiDhV55dvfzqtmP4S3sy3T3ouV2tkViZgxY/zeUkcpQcQPGRlgF4KmWzWW5oiWYSZwtCB+2RsE4Fw==
"@types/jest@^26.0.22":
version "26.0.22"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.22.tgz#8308a1debdf1b807aa47be2838acdcd91e88fbe6"
integrity sha512-eeWwWjlqxvBxc4oQdkueW5OF/gtfSceKk4OnOAGlUSwS/liBRtZppbJuz1YkgbrbfGOoeBHun9fOvXnjNwrSOw==
dependencies:
jest-diff "^25.2.1"
pretty-format "^25.2.1"
jest-diff "^26.0.0"
pretty-format "^26.0.0"
"@types/js-yaml@^3.12.1", "@types/js-yaml@^3.12.4":
version "3.12.4"
@ -13660,7 +13667,7 @@ ts-essentials@^4.0.0:
resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-4.0.0.tgz#506c42b270bbd0465574b90416533175b09205ab"
integrity sha512-uQJX+SRY9mtbKU+g9kl5Fi7AEMofPCvHfJkQlaygpPmHPZrtgaBqbWFOYyiA47RhnSwwnXdepUJrgqUYxoUyhQ==
ts-jest@^26.1.0:
ts-jest@26.3.0:
version "26.3.0"
resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-26.3.0.tgz#6b2845045347dce394f069bb59358253bc1338a9"
integrity sha512-Jq2uKfx6bPd9+JDpZNMBJMdMQUC3sJ08acISj8NXlVgR2d5OqslEHOR2KHMgwymu8h50+lKIm0m0xj/ioYdW2Q==