diff --git a/src/common/fs/create-read-file-stream.injectable.ts b/src/common/fs/create-read-file-stream.injectable.ts new file mode 100644 index 0000000000..8714e1cdcd --- /dev/null +++ b/src/common/fs/create-read-file-stream.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { ReadStream } from "fs"; +import fsInjectable from "./fs.injectable"; + +export interface CreateReadStreamOptions { + mode?: number; + end?: number | undefined; + flags?: string | undefined; + encoding?: BufferEncoding | undefined; + autoClose?: boolean | undefined; + /** + * @default false + */ + emitClose?: boolean | undefined; + start?: number | undefined; + highWaterMark?: number | undefined; +} + +export type CreateReadFileStream = (filePath: string, options?: CreateReadStreamOptions) => ReadStream; + +const createReadFileStreamInjectable = getInjectable({ + id: "create-read-file-stream", + instantiate: (di): CreateReadFileStream => di.inject(fsInjectable).createReadStream, +}); + +export default createReadFileStreamInjectable; diff --git a/src/common/fs/stat/stat.injectable.ts b/src/common/fs/stat/stat.injectable.ts index aa1ce44447..e9924fc088 100644 --- a/src/common/fs/stat/stat.injectable.ts +++ b/src/common/fs/stat/stat.injectable.ts @@ -3,12 +3,14 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; +import type { Stats } from "fs"; import fsInjectable from "../fs.injectable"; +export type Stat = (path: string) => Promise; + const statInjectable = getInjectable({ id: "stat", - - instantiate: (di) => di.inject(fsInjectable).stat, + instantiate: (di): Stat => di.inject(fsInjectable).stat, }); export default statInjectable; diff --git a/src/common/fs/watch/watch.injectable.ts b/src/common/fs/watch/watch.injectable.ts index 44d34f20f5..50f96cdf57 100644 --- a/src/common/fs/watch/watch.injectable.ts +++ b/src/common/fs/watch/watch.injectable.ts @@ -3,15 +3,161 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import type { FSWatcher, WatchOptions } from "chokidar"; import { watch } from "chokidar"; +import type { Stats } from "fs"; +import type TypedEventEmitter from "typed-emitter"; +import type { SingleOrMany } from "../../utils"; -export type Watch = (path: string, options?: WatchOptions) => FSWatcher; +export interface AlwaysStatWatcherEvents { + add: (path: string, stats: Stats) => void; + addDir: (path: string, stats: Stats) => void; + change: (path: string, stats: Stats) => void; +} + +export interface MaybeStatWatcherEvents { + add: (path: string, stats?: Stats) => void; + addDir: (path: string, stats?: Stats) => void; + change: (path: string, stats?: Stats) => void; +} + +export type WatcherEvents = BaseWatcherEvents + & ( + AlwaysStat extends true + ? AlwaysStatWatcherEvents + : MaybeStatWatcherEvents + ); + +export interface BaseWatcherEvents { + error: (error: Error) => void; + ready: () => void; + unlink: (path: string) => void; + unlinkDir: (path: string) => void; +} + +export interface Watcher extends TypedEventEmitter> { + close: () => Promise; +} + +export type WatcherOptions = { + /** + * Indicates whether the process should continue to run as long as files are being watched. If + * set to `false` when using `fsevents` to watch, no more events will be emitted after `ready`, + * even if the process continues to run. + */ + persistent?: boolean; + + /** + * ([anymatch](https://github.com/micromatch/anymatch)-compatible definition) Defines files/paths to + * be ignored. The whole relative or absolute path is tested, not just filename. If a function + * with two arguments is provided, it gets called twice per path - once with a single argument + * (the path), second time with two arguments (the path and the + * [`fs.Stats`](https://nodejs.org/api/fs.html#fs_class_fs_stats) object of that path). + */ + ignored?: SingleOrMany boolean)>; + + /** + * If set to `false` then `add`/`addDir` events are also emitted for matching paths while + * instantiating the watching as chokidar discovers these file paths (before the `ready` event). + */ + ignoreInitial?: boolean; + + /** + * When `false`, only the symlinks themselves will be watched for changes instead of following + * the link references and bubbling events through the link's path. + */ + followSymlinks?: boolean; + + /** + * The base directory from which watch `paths` are to be derived. Paths emitted with events will + * be relative to this. + */ + cwd?: string; + + /** + * If set to true then the strings passed to .watch() and .add() are treated as literal path + * names, even if they look like globs. Default: false. + */ + disableGlobbing?: boolean; + + /** + * Whether to use fs.watchFile (backed by polling), or fs.watch. If polling leads to high CPU + * utilization, consider setting this to `false`. It is typically necessary to **set this to + * `true` to successfully watch files over a network**, and it may be necessary to successfully + * watch files in other non-standard situations. Setting to `true` explicitly on OS X overrides + * the `useFsEvents` default. + */ + usePolling?: boolean; + + /** + * Whether to use the `fsevents` watching interface if available. When set to `true` explicitly + * and `fsevents` is available this supercedes the `usePolling` setting. When set to `false` on + * OS X, `usePolling: true` becomes the default. + */ + useFsEvents?: boolean; + + /** + * If set, limits how many levels of subdirectories will be traversed. + */ + depth?: number; + + /** + * Interval of file system polling. + */ + interval?: number; + + /** + * Interval of file system polling for binary files. ([see list of binary extensions](https://gi + * thub.com/sindresorhus/binary-extensions/blob/master/binary-extensions.json)) + */ + binaryInterval?: number; + + /** + * Indicates whether to watch files that don't have read permissions if possible. If watching + * fails due to `EPERM` or `EACCES` with this set to `true`, the errors will be suppressed + * silently. + */ + ignorePermissionErrors?: boolean; + + /** + * `true` if `useFsEvents` and `usePolling` are `false`). Automatically filters out artifacts + * that occur when using editors that use "atomic writes" instead of writing directly to the + * source file. If a file is re-added within 100 ms of being deleted, Chokidar emits a `change` + * event rather than `unlink` then `add`. If the default of 100 ms does not work well for you, + * you can override it by setting `atomic` to a custom value, in milliseconds. + */ + atomic?: boolean | number; + + /** + * can be set to an object in order to adjust timing params: + */ + awaitWriteFinish?: AwaitWriteFinishOptions | boolean; +} & (AlwaysStat extends true + ? { + alwaysStat: true; + } + : { + alwaysStat?: false; + } +); + +export interface AwaitWriteFinishOptions { + /** + * Amount of time in milliseconds for a file size to remain constant before emitting its event. + */ + stabilityThreshold?: number; + + /** + * File size polling interval. + */ + pollInterval?: number; +} + +export type Watch = (path: string, options?: WatcherOptions) => Watcher; // TODO: Introduce wrapper to allow simpler API const watchInjectable = getInjectable({ id: "watch", - instantiate: (): Watch => watch, + instantiate: () => watch as Watch, causesSideEffects: true, }); diff --git a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts index 6cb8b9f3aa..2998022e67 100644 --- a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts +++ b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts @@ -3,72 +3,49 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { observable, ObservableMap, when } from "mobx"; +import { observable, ObservableMap } from "mobx"; import type { CatalogEntity } from "../../../common/catalog"; import { loadFromOptions } from "../../../common/kube-helpers"; import type { Cluster } from "../../../common/cluster/cluster"; -import mockFs from "mock-fs"; -import fs from "fs"; -import clusterStoreInjectable from "../../../common/cluster-store/cluster-store.injectable"; import { getDiForUnitTesting } from "../../getDiForUnitTesting"; -import getConfigurationFileModelInjectable from "../../../common/get-configuration-file-model/get-configuration-file-model.injectable"; import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import directoryForTempInjectable from "../../../common/app-paths/directory-for-temp/directory-for-temp.injectable"; -import kubectlBinaryNameInjectable from "../../kubectl/binary-name.injectable"; -import kubectlDownloadingNormalizedArchInjectable from "../../kubectl/normalized-arch.injectable"; -import normalizedPlatformInjectable from "../../../common/vars/normalized-platform.injectable"; -import { iter } from "../../../common/utils"; -import fsInjectable from "../../../common/fs/fs.injectable"; +import { iter, strictGet } from "../../../common/utils"; import type { ComputeKubeconfigDiff } from "../kubeconfig-sync/compute-diff.injectable"; import computeKubeconfigDiffInjectable from "../kubeconfig-sync/compute-diff.injectable"; -import watchInjectable from "../../../common/fs/watch/watch.injectable"; import type { ConfigToModels } from "../kubeconfig-sync/config-to-models.injectable"; import configToModelsInjectable from "../kubeconfig-sync/config-to-models.injectable"; import kubeconfigSyncManagerInjectable from "../kubeconfig-sync/manager.injectable"; import type { KubeconfigSyncManager } from "../kubeconfig-sync/manager"; import type { KubeconfigSyncValue } from "../../../common/user-store"; import kubeconfigSyncsInjectable from "../../../common/user-store/kubeconfig-syncs.injectable"; - -console.log("This is a reminder that mockFS breaks things and needs to be removed"); - -jest.mock("electron", () => ({ - app: { - getVersion: () => "99.99.99", - getName: () => "lens", - setName: jest.fn(), - setPath: jest.fn(), - getPath: () => "tmp", - getLocale: () => "en", - setLoginItemSettings: jest.fn(), - }, - ipcMain: { - on: jest.fn(), - handle: jest.fn(), - }, -})); +import getClusterByIdInjectable from "../../../common/cluster-store/get-by-id.injectable"; +import type { DiContainer } from "@ogre-tools/injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import type { Stat } from "../../../common/fs/stat/stat.injectable"; +import asyncFn from "@async-fn/jest"; +import statInjectable from "../../../common/fs/stat/stat.injectable"; +import type { Watcher } from "../../../common/fs/watch/watch.injectable"; +import watchInjectable from "../../../common/fs/watch/watch.injectable"; +import EventEmitter from "events"; +import type { ReadStream, Stats } from "fs"; +import createReadFileStreamInjectable from "../../../common/fs/create-read-file-stream.injectable"; describe("kubeconfig-sync.source tests", () => { let computeKubeconfigDiff: ComputeKubeconfigDiff; let configToModels: ConfigToModels; - let manager: KubeconfigSyncManager; let kubeconfigSyncs: ObservableMap; + let clusters: Map; + let di: DiContainer; beforeEach(async () => { - const di = getDiForUnitTesting({ doGeneralOverrides: true }); + di = getDiForUnitTesting({ doGeneralOverrides: true }); - mockFs(); + di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data"); + di.override(directoryForTempInjectable, () => "/some-directory-for-temp"); - di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); - di.override(directoryForTempInjectable, () => "some-directory-for-temp"); - di.override(kubectlBinaryNameInjectable, () => "kubectl"); - di.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64"); - di.override(normalizedPlatformInjectable, () => "darwin"); - - di.permitSideEffects(fsInjectable); - di.permitSideEffects(watchInjectable); - di.unoverride(clusterStoreInjectable); - di.permitSideEffects(clusterStoreInjectable); - di.permitSideEffects(getConfigurationFileModelInjectable); + clusters = new Map(); + di.override(getClusterByIdInjectable, () => id => clusters.get(id)); kubeconfigSyncs = observable.map(); @@ -76,11 +53,6 @@ describe("kubeconfig-sync.source tests", () => { computeKubeconfigDiff = di.inject(computeKubeconfigDiffInjectable); configToModels = di.inject(configToModelsInjectable); - manager = di.inject(kubeconfigSyncManagerInjectable); - }); - - afterEach(() => { - mockFs.restore(); }); describe("configsToModels", () => { @@ -162,8 +134,6 @@ describe("kubeconfig-sync.source tests", () => { const rootSource = new ObservableMap(); const filePath = "/bar"; - fs.writeFileSync(filePath, contents); - computeKubeconfigDiff(contents, rootSource, filePath); expect(rootSource.size).toBe(1); @@ -206,8 +176,6 @@ describe("kubeconfig-sync.source tests", () => { const rootSource = new ObservableMap(); const filePath = "/bar"; - fs.writeFileSync(filePath, contents); - computeKubeconfigDiff(contents, rootSource, filePath); expect(rootSource.size).toBe(1); @@ -260,8 +228,6 @@ describe("kubeconfig-sync.source tests", () => { const rootSource = new ObservableMap(); const filePath = "/bar"; - fs.writeFileSync(filePath, contents); - computeKubeconfigDiff(contents, rootSource, filePath); expect(rootSource.size).toBe(2); @@ -316,34 +282,47 @@ describe("kubeconfig-sync.source tests", () => { }); describe("given a config file at /foobar/config", () => { + let manager: KubeconfigSyncManager; + let watchInstances: Map>; + let firstReadFoobarConfigSteam: ReadStream; + let secondReadFoobarConfigSteam: ReadStream; + let statMock: AsyncFnMock; + beforeEach(() => { - fs.mkdirSync("/foobar"); - fs.writeFileSync("/foobar/config", 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", - })); + statMock = asyncFn(); + di.override(statInjectable, () => statMock); + + watchInstances = new Map(); + di.override(watchInjectable, () => (path) => { + const fakeWatchInstance = getFakeWatchInstance(); + + watchInstances.set(path, fakeWatchInstance); + + return fakeWatchInstance; + }); + + di.override(createReadFileStreamInjectable, () => (filePath) => { + if (filePath !== "/foobar/config") { + throw new Error(`unexpected file path "${filePath}"`); + } + + if (!firstReadFoobarConfigSteam) { + return firstReadFoobarConfigSteam = getFakeReadStream(filePath); + } + + if (!secondReadFoobarConfigSteam) { + return secondReadFoobarConfigSteam = getFakeReadStream(filePath); + } + + return getFakeReadStream(filePath); + }); + + manager = di.inject(kubeconfigSyncManagerInjectable); + }); + + afterEach(() => { + (firstReadFoobarConfigSteam as any) = undefined; + (secondReadFoobarConfigSteam as any) = undefined; }); it("should not find any entities", () => { @@ -364,30 +343,118 @@ describe("kubeconfig-sync.source tests", () => { kubeconfigSyncs.set("/foobar/config", {}); }); - it("should find a single entity", (done) => { - when(() => manager.source.get().length === 1, () => done()); - }); - - describe("when a folder sync target for /foobar is added", () => { - beforeEach(() => { - kubeconfigSyncs.set("/foobar", {}); + describe("when stat resolves as not a directory", () => { + beforeEach(async () => { + await statMock.resolveSpecific(["/foobar/config"], { + isDirectory: () => false, + } as Stats); }); - it("should still only find a single entity", (done) => { - when(() => manager.source.get().length === 1, () => done()); + describe("when the watch emits that the file is added", () => { + beforeEach(() => { + strictGet(watchInstances, "/foobar/config").emit("add", "/foobar/config", { + size: foobarConfig.length, + } as Stats); + }); + + it("starts to read the file", () => { + expect(firstReadFoobarConfigSteam).toBeDefined(); + }); + + describe("when the data is read in", () => { + beforeEach(() => { + firstReadFoobarConfigSteam.emit("data", Buffer.from(foobarConfig)); + firstReadFoobarConfigSteam.emit("end"); + firstReadFoobarConfigSteam.emit("close"); + }); + + it("should find a single entity", () => { + expect(manager.source.get().length).toBe(1); + }); + + describe("when a folder sync target for /foobar is added", () => { + beforeEach(() => { + kubeconfigSyncs.set("/foobar", {}); + }); + + describe("when stat resolves as not a directory", () => { + beforeEach(async () => { + await statMock.resolveSpecific(["/foobar"], { + isDirectory: () => true, + } as Stats); + }); + + describe("when the watch emits that the file is added", () => { + beforeEach(() => { + strictGet(watchInstances, "/foobar").emit("add", "/foobar/config", { + size: foobarConfig.length, + } as Stats); + }); + + it("starts to read the file", () => { + expect(secondReadFoobarConfigSteam).toBeDefined(); + }); + + describe("when the data is read in", () => { + beforeEach(() => { + secondReadFoobarConfigSteam.emit("data", Buffer.from(foobarConfig)); + secondReadFoobarConfigSteam.emit("end"); + secondReadFoobarConfigSteam.emit("close"); + }); + + it("should still only find a single entity", () => { + expect(manager.source.get().length).toBe(1); + }); + }); + }); + }); + }); + }); }); }); }); - - describe("when a folder sync target for /foobar is added", () => { - beforeEach(() => { - kubeconfigSyncs.set("/foobar", {}); - }); - - it("should find a single entity", (done) => { - when(() => manager.source.get().length === 1, () => done()); - }); - }); }); }); }); + +const getFakeWatchInstance = (): Watcher => { + return Object.assign(new EventEmitter(), { + close: jest.fn().mockImplementation(async () => {}), + }); +}; + +const getFakeReadStream = (path: string): ReadStream => { + return Object.assign(new EventEmitter(), { + path, + close: () => {}, + push: () => true, + read: () => {}, + }) as unknown as ReadStream; +}; + +const foobarConfig = 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", +}); diff --git a/src/main/catalog-sources/kubeconfig-sync/compute-diff.injectable.ts b/src/main/catalog-sources/kubeconfig-sync/compute-diff.injectable.ts index fbf4705406..cb1e62e6e8 100644 --- a/src/main/catalog-sources/kubeconfig-sync/compute-diff.injectable.ts +++ b/src/main/catalog-sources/kubeconfig-sync/compute-diff.injectable.ts @@ -12,8 +12,8 @@ import type { CatalogEntity } from "../../../common/catalog"; import getClusterByIdInjectable from "../../../common/cluster-store/get-by-id.injectable"; import type { Cluster } from "../../../common/cluster/cluster"; import { loadConfigFromString } from "../../../common/kube-helpers"; +import clustersThatAreBeingDeletedInjectable from "../../cluster/are-being-deleted.injectable"; import { catalogEntityFromCluster } from "../../cluster/manager"; -import clusterManagerInjectable from "../../cluster/manager.injectable"; import createClusterInjectable from "../../create-cluster/create-cluster.injectable"; import configToModelsInjectable from "./config-to-models.injectable"; import kubeconfigSyncLoggerInjectable from "./logger.injectable"; @@ -25,7 +25,7 @@ const computeKubeconfigDiffInjectable = getInjectable({ instantiate: (di): ComputeKubeconfigDiff => { const directoryForKubeConfigs = di.inject(directoryForKubeConfigsInjectable); const createCluster = di.inject(createClusterInjectable); - const clusterManager = di.inject(clusterManagerInjectable); + const clustersThatAreBeingDeleted = di.inject(clustersThatAreBeingDeletedInjectable); const configToModels = di.inject(configToModelsInjectable); const logger = di.inject(kubeconfigSyncLoggerInjectable); const getClusterById = di.inject(getClusterByIdInjectable); @@ -48,8 +48,8 @@ const computeKubeconfigDiffInjectable = getInjectable({ // remove and disconnect clusters that were removed from the config if (!data) { - // remove from the deleting set, so that if a new context of the same name is added, it isn't marked as deleting - clusterManager.deleting.delete(value[0].id); + // remove from the deleting set, so that if a new context of the same name is added, it isn't marked as deleting + clustersThatAreBeingDeleted.delete(value[0].id); value[0].disconnect(); source.delete(contextName); @@ -68,7 +68,7 @@ const computeKubeconfigDiffInjectable = getInjectable({ } for (const [contextName, [model, configData]] of models) { - // add new clusters to the source + // add new clusters to the source try { const clusterId = createHash("md5").update(`${filePath}:${contextName}`).digest("hex"); const cluster = getClusterById(clusterId) ?? createCluster({ ...model, id: clusterId }, configData); @@ -93,6 +93,8 @@ const computeKubeconfigDiffInjectable = getInjectable({ logger.warn(`Failed to compute diff: ${error}`, { filePath }); source.clear(); // clear source if we have failed so as to not show outdated information } + + logger.debug("Finished computing diff", { filePath }); }); }, }); diff --git a/src/main/catalog-sources/kubeconfig-sync/diff-changed-kubeconfig.injectable.ts b/src/main/catalog-sources/kubeconfig-sync/diff-changed-kubeconfig.injectable.ts index a35f41c028..f17293fc2d 100644 --- a/src/main/catalog-sources/kubeconfig-sync/diff-changed-kubeconfig.injectable.ts +++ b/src/main/catalog-sources/kubeconfig-sync/diff-changed-kubeconfig.injectable.ts @@ -9,7 +9,7 @@ import type { ObservableMap } from "mobx"; import type { Readable } from "stream"; import type { CatalogEntity } from "../../../common/catalog"; import type { Cluster } from "../../../common/cluster/cluster"; -import fsInjectable from "../../../common/fs/fs.injectable"; +import createReadFileStreamInjectable from "../../../common/fs/create-read-file-stream.injectable"; import type { Disposer } from "../../../common/utils"; import { bytesToUnits, noop } from "../../../common/utils"; import computeKubeconfigDiffInjectable from "./compute-diff.injectable"; @@ -28,7 +28,7 @@ const diffChangedKubeconfigInjectable = getInjectable({ instantiate: (di): DiffChangedKubeconfig => { const computeKubeconfigDiff = di.inject(computeKubeconfigDiffInjectable); const logger = di.inject(kubeconfigSyncLoggerInjectable); - const { createReadStream } = di.inject(fsInjectable); + const createReadFileStream = di.inject(createReadFileStreamInjectable); return ({ filePath, maxAllowedFileReadSize, source, stats }) => { logger.debug(`file changed`, { filePath }); @@ -40,7 +40,7 @@ const diffChangedKubeconfigInjectable = getInjectable({ return noop; } - const fileReader = createReadStream(filePath, { + const fileReader = createReadFileStream(filePath, { mode: constants.O_RDONLY, }); const readStream = fileReader as Readable; diff --git a/src/main/catalog-sources/kubeconfig-sync/manager.ts b/src/main/catalog-sources/kubeconfig-sync/manager.ts index 6e284bd2ca..305f25a909 100644 --- a/src/main/catalog-sources/kubeconfig-sync/manager.ts +++ b/src/main/catalog-sources/kubeconfig-sync/manager.ts @@ -36,11 +36,13 @@ export class KubeconfigSyncManager { return ( iter.pipeline(this.sources.values()) .flatMap(([entities]) => entities.get()) - .filter(entity => ( - seenIds.has(entity.getId()) - ? false - : seenIds.add(entity.getId()) - )) + .filter(entity => { + const alreadySeen = seenIds.has(entity.getId()); + + seenIds.add(entity.getId()); + + return !alreadySeen; + }) .collect(items => [...items]) ); }); diff --git a/src/main/catalog-sources/kubeconfig-sync/watch-file-changes.injectable.ts b/src/main/catalog-sources/kubeconfig-sync/watch-file-changes.injectable.ts index 2fb03c12d4..c843222a5f 100644 --- a/src/main/catalog-sources/kubeconfig-sync/watch-file-changes.injectable.ts +++ b/src/main/catalog-sources/kubeconfig-sync/watch-file-changes.injectable.ts @@ -3,8 +3,6 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import type { FSWatcher } from "chokidar"; -import type { Stats } from "fs"; import GlobToRegExp from "glob-to-regexp"; import type { IComputedValue, ObservableMap } from "mobx"; import { computed, observable } from "mobx"; @@ -12,7 +10,8 @@ import path from "path"; import { inspect } from "util"; import type { CatalogEntity } from "../../../common/catalog"; import type { Cluster } from "../../../common/cluster/cluster"; -import fsInjectable from "../../../common/fs/fs.injectable"; +import statInjectable from "../../../common/fs/stat/stat.injectable"; +import type { Watcher } from "../../../common/fs/watch/watch.injectable"; import watchInjectable from "../../../common/fs/watch/watch.injectable"; import type { Disposer } from "../../../common/utils"; import { getOrInsertWith, iter } from "../../../common/utils"; @@ -47,14 +46,14 @@ const watchKubeconfigFileChangesInjectable = getInjectable({ instantiate: (di): WatchKubeconfigFileChanges => { const diffChangedKubeconfig = di.inject(diffChangedKubeconfigInjectable); const logger = di.inject(kubeconfigSyncLoggerInjectable); - const { stat } = di.inject(fsInjectable); + const stat = di.inject(statInjectable); const watch = di.inject(watchInjectable); return (filePath) => { const rootSource = observable.map>(); const derivedSource = computed(() => Array.from(iter.flatMap(rootSource.values(), from => iter.map(from.values(), child => child[1])))); - let watcher: FSWatcher; + let watcher: Watcher; (async () => { try { @@ -65,7 +64,7 @@ const watchKubeconfigFileChangesInjectable = getInjectable({ ? folderSyncMaxAllowedFileReadSize : fileSyncMaxAllowedFileReadSize; - watcher = watch(filePath, { + watcher = watch(filePath, { followSymlinks: true, depth: isFolderSync ? 0 : 1, // DIRs works with 0 but files need 1 (bug: https://github.com/paulmillr/chokidar/issues/1095) disableGlobbing: true, @@ -80,7 +79,7 @@ const watchKubeconfigFileChangesInjectable = getInjectable({ }); watcher - .on("change", (childFilePath, stats: Stats): void => { + .on("change", (childFilePath, stats): void => { const cleanup = cleanupFns.get(childFilePath); if (!cleanup) { @@ -96,7 +95,7 @@ const watchKubeconfigFileChangesInjectable = getInjectable({ maxAllowedFileReadSize, })); }) - .on("add", (childFilePath, stats: Stats): void => { + .on("add", (childFilePath, stats): void => { if (isFolderSync) { const fileName = path.basename(childFilePath); diff --git a/src/main/cluster/are-being-deleted.injectable.ts b/src/main/cluster/are-being-deleted.injectable.ts new file mode 100644 index 0000000000..610791aee7 --- /dev/null +++ b/src/main/cluster/are-being-deleted.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { observable } from "mobx"; +import type { ClusterId } from "../../common/cluster-types"; + +const clustersThatAreBeingDeletedInjectable = getInjectable({ + id: "clusters-that-are-being-deleted", + instantiate: () => observable.set(), +}); + +export default clustersThatAreBeingDeletedInjectable; diff --git a/src/main/cluster/manager.injectable.ts b/src/main/cluster/manager.injectable.ts index 842c2de9d1..13f04f16ac 100644 --- a/src/main/cluster/manager.injectable.ts +++ b/src/main/cluster/manager.injectable.ts @@ -5,6 +5,7 @@ import { getInjectable } from "@ogre-tools/injectable"; import clusterStoreInjectable from "../../common/cluster-store/cluster-store.injectable"; import catalogEntityRegistryInjectable from "../catalog/entity-registry.injectable"; +import clustersThatAreBeingDeletedInjectable from "./are-being-deleted.injectable"; import { ClusterManager } from "./manager"; const clusterManagerInjectable = getInjectable({ @@ -13,6 +14,7 @@ const clusterManagerInjectable = getInjectable({ instantiate: (di) => new ClusterManager({ store: di.inject(clusterStoreInjectable), catalogEntityRegistry: di.inject(catalogEntityRegistryInjectable), + clustersThatAreBeingDeleted: di.inject(clustersThatAreBeingDeletedInjectable), }), }); diff --git a/src/main/cluster/manager.ts b/src/main/cluster/manager.ts index b04df23fd5..8ae6e5ce99 100644 --- a/src/main/cluster/manager.ts +++ b/src/main/cluster/manager.ts @@ -5,6 +5,7 @@ import "../../common/ipc/cluster"; import type http from "http"; +import type { ObservableSet } from "mobx"; import { action, makeObservable, observable, observe, reaction, toJS } from "mobx"; import type { Cluster } from "../../common/cluster/cluster"; import logger from "../logger"; @@ -23,16 +24,15 @@ const logPrefix = "[CLUSTER-MANAGER]:"; const lensSpecificClusterStatuses: Set = new Set(Object.values(LensKubernetesClusterStatus)); interface Dependencies { - store: ClusterStore; - catalogEntityRegistry: CatalogEntityRegistry; + readonly store: ClusterStore; + readonly catalogEntityRegistry: CatalogEntityRegistry; + readonly clustersThatAreBeingDeleted: ObservableSet; } export class ClusterManager { - deleting = observable.set(); - @observable visibleCluster: ClusterId | undefined = undefined; - constructor(private dependencies: Dependencies) { + constructor(private readonly dependencies: Dependencies) { makeObservable(this); } @@ -69,7 +69,7 @@ export class ClusterManager { } }); - observe(this.deleting, change => { + observe(this.dependencies.clustersThatAreBeingDeleted, change => { if (change.type === "add") { this.updateEntityStatus(this.dependencies.catalogEntityRegistry.findById(change.newValue) as KubernetesCluster); } @@ -141,7 +141,7 @@ export class ClusterManager { @action protected updateEntityStatus(entity: KubernetesCluster, cluster?: Cluster) { - if (this.deleting.has(entity.getId())) { + if (this.dependencies.clustersThatAreBeingDeleted.has(entity.getId())) { entity.status.phase = LensKubernetesClusterStatus.DELETING; entity.status.enabled = false; } else { diff --git a/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.injectable.ts b/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.injectable.ts index f07ac94fec..148b9d7bf3 100644 --- a/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.injectable.ts +++ b/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.injectable.ts @@ -14,6 +14,7 @@ import { onLoadOfApplicationInjectionToken } from "../../../start-main-applicati import operatingSystemThemeInjectable from "../../../theme/operating-system-theme.injectable"; import catalogEntityRegistryInjectable from "../../../catalog/entity-registry.injectable"; import askUserForFilePathsInjectable from "../../../ipc/ask-user-for-file-paths.injectable"; +import clustersThatAreBeingDeletedInjectable from "../../../cluster/are-being-deleted.injectable"; const setupIpcMainHandlersInjectable = getInjectable({ id: "setup-ipc-main-handlers", @@ -32,6 +33,7 @@ const setupIpcMainHandlersInjectable = getInjectable({ const clusterStore = di.inject(clusterStoreInjectable); const operatingSystemTheme = di.inject(operatingSystemThemeInjectable); const askUserForFilePaths = di.inject(askUserForFilePathsInjectable); + const clustersThatAreBeingDeleted = di.inject(clustersThatAreBeingDeletedInjectable); return { run: () => { @@ -46,6 +48,7 @@ const setupIpcMainHandlersInjectable = getInjectable({ clusterStore, operatingSystemTheme, askUserForFilePaths, + clustersThatAreBeingDeleted, }); }, }; diff --git a/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.ts b/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.ts index c4abc2b2d5..6f873cd2c3 100644 --- a/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.ts +++ b/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.ts @@ -15,7 +15,7 @@ import { pushCatalogToRenderer } from "../../../catalog-pusher"; import type { ClusterManager } from "../../../cluster/manager"; import { ResourceApplier } from "../../../resource-applier"; import { remove } from "fs-extra"; -import type { IComputedValue } from "mobx"; +import type { IComputedValue, ObservableSet } from "mobx"; import type { GetAbsolutePath } from "../../../../common/path/get-absolute-path.injectable"; import type { MenuItemOpts } from "../../../menu/application-menu-items.injectable"; import { windowActionHandleChannel, windowLocationChangedChannel, windowOpenAppMenuAsContextMenuChannel } from "../../../../common/ipc/window"; @@ -34,9 +34,20 @@ interface Dependencies { clusterStore: ClusterStore; operatingSystemTheme: IComputedValue; askUserForFilePaths: AskUserForFilePaths; + clustersThatAreBeingDeleted: ObservableSet; } -export const setupIpcMainHandlers = ({ applicationMenuItems, directoryForLensLocalStorage, getAbsolutePath, clusterManager, catalogEntityRegistry, clusterStore, operatingSystemTheme, askUserForFilePaths }: Dependencies) => { +export const setupIpcMainHandlers = ({ + applicationMenuItems, + directoryForLensLocalStorage, + getAbsolutePath, + clusterManager, + catalogEntityRegistry, + clusterStore, + operatingSystemTheme, + askUserForFilePaths, + clustersThatAreBeingDeleted, +}: Dependencies) => { ipcMainHandle(clusterActivateHandler, (event, clusterId: ClusterId, force = false) => { return ClusterStore.getInstance() .getById(clusterId) @@ -101,11 +112,11 @@ export const setupIpcMainHandlers = ({ applicationMenuItems, directoryForLensLoc }); ipcMainHandle(clusterSetDeletingHandler, (event, clusterId: string) => { - clusterManager.deleting.add(clusterId); + clustersThatAreBeingDeleted.add(clusterId); }); ipcMainHandle(clusterClearDeletingHandler, (event, clusterId: string) => { - clusterManager.deleting.delete(clusterId); + clustersThatAreBeingDeleted.delete(clusterId); }); ipcMainHandle(clusterKubectlApplyAllHandler, async (event, clusterId: ClusterId, resources: string[], extraArgs: string[]) => {