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

Add eslint rule padding-line-between-statements (#1593)

Signed-off-by: Panu Horsmalahti <phorsmalahti@mirantis.com>
This commit is contained in:
Panu Horsmalahti 2020-12-02 09:55:52 +02:00 committed by GitHub
parent 7b77f18376
commit dcf253e7d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
401 changed files with 2018 additions and 40 deletions

View File

@ -49,6 +49,15 @@ module.exports = {
"object-shorthand": "error",
"prefer-template": "error",
"template-curly-spacing": "error",
"padding-line-between-statements": [
"error",
{ "blankLine": "always", "prev": "*", "next": "return" },
{ "blankLine": "always", "prev": "*", "next": "block-like" },
{ "blankLine": "always", "prev": "*", "next": "function" },
{ "blankLine": "always", "prev": "*", "next": "class" },
{ "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" },
{ "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"]},
]
}
},
{
@ -94,6 +103,15 @@ module.exports = {
"object-shorthand": "error",
"prefer-template": "error",
"template-curly-spacing": "error",
"padding-line-between-statements": [
"error",
{ "blankLine": "always", "prev": "*", "next": "return" },
{ "blankLine": "always", "prev": "*", "next": "block-like" },
{ "blankLine": "always", "prev": "*", "next": "function" },
{ "blankLine": "always", "prev": "*", "next": "class" },
{ "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" },
{ "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"]},
]
},
},
{
@ -146,6 +164,15 @@ module.exports = {
"object-shorthand": "error",
"prefer-template": "error",
"template-curly-spacing": "error",
"padding-line-between-statements": [
"error",
{ "blankLine": "always", "prev": "*", "next": "return" },
{ "blankLine": "always", "prev": "*", "next": "block-like" },
{ "blankLine": "always", "prev": "*", "next": "function" },
{ "blankLine": "always", "prev": "*", "next": "class" },
{ "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" },
{ "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"]},
]
},
}
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,8 +31,10 @@ describe("empty config", () => {
"lens-cluster-store.json": JSON.stringify({})
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
});
@ -59,6 +61,7 @@ describe("empty config", () => {
it("adds new cluster to store", async () => {
const storedCluster = clusterStore.getById("foo");
expect(storedCluster.id).toBe("foo");
expect(storedCluster.preferences.terminalCWD).toBe("/tmp");
expect(storedCluster.preferences.icon).toBe("data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5");
@ -67,6 +70,7 @@ describe("empty config", () => {
it("adds cluster to default workspace", () => {
const storedCluster = clusterStore.getById("foo");
expect(storedCluster.workspace).toBe("default");
});
@ -114,6 +118,7 @@ describe("empty config", () => {
it("gets clusters by workspaces", () => {
const wsClusters = clusterStore.getByWorkspaceId("workstation");
const defaultClusters = clusterStore.getByWorkspaceId("default");
expect(defaultClusters.length).toBe(0);
expect(wsClusters.length).toBe(2);
expect(wsClusters[0].id).toBe("prod");
@ -122,6 +127,7 @@ describe("empty config", () => {
it("check if cluster's kubeconfig file saved", () => {
const file = ClusterStore.embedCustomKubeConfig("boo", "kubeconfig");
expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig");
});
@ -129,6 +135,7 @@ describe("empty config", () => {
clusterStore.swapIconOrders("workstation", 1, 1);
const clusters = clusterStore.getByWorkspaceId("workstation");
expect(clusters[0].id).toBe("prod");
expect(clusters[0].preferences.iconOrder).toBe(0);
expect(clusters[1].id).toBe("dev");
@ -139,6 +146,7 @@ describe("empty config", () => {
clusterStore.swapIconOrders("workstation", 0, 1);
const clusters = clusterStore.getByWorkspaceId("workstation");
expect(clusters[0].id).toBe("dev");
expect(clusters[0].preferences.iconOrder).toBe(0);
expect(clusters[1].id).toBe("prod");
@ -192,8 +200,10 @@ describe("config with existing clusters", () => {
})
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
});
@ -203,6 +213,7 @@ describe("config with existing clusters", () => {
it("allows to retrieve a cluster", () => {
const storedCluster = clusterStore.getById("cluster1");
expect(storedCluster.id).toBe("cluster1");
expect(storedCluster.preferences.terminalCWD).toBe("/foo");
});
@ -210,13 +221,16 @@ describe("config with existing clusters", () => {
it("allows to delete a cluster", () => {
clusterStore.removeById("cluster2");
const storedCluster = clusterStore.getById("cluster1");
expect(storedCluster).toBeTruthy();
const storedCluster2 = clusterStore.getById("cluster2");
expect(storedCluster2).toBeUndefined();
});
it("allows getting all of the clusters", async () => {
const storedClusters = clusterStore.clustersList;
expect(storedClusters.length).toBe(3);
expect(storedClusters[0].id).toBe("cluster1");
expect(storedClusters[0].preferences.terminalCWD).toBe("/foo");
@ -227,6 +241,7 @@ describe("config with existing clusters", () => {
it("marks owned cluster disabled by default", () => {
const storedClusters = clusterStore.clustersList;
expect(storedClusters[0].enabled).toBe(true);
expect(storedClusters[2].enabled).toBe(false);
});
@ -247,8 +262,10 @@ describe("pre 2.0 config with an existing cluster", () => {
})
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
});
@ -258,6 +275,7 @@ describe("pre 2.0 config with an existing cluster", () => {
it("migrates to modern format with kubeconfig in a file", async () => {
const config = clusterStore.clustersList[0].kubeConfigPath;
expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content");
});
});
@ -279,8 +297,10 @@ describe("pre 2.6.0 config with a cluster that has arrays in auth config", () =>
})
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
});
@ -292,6 +312,7 @@ describe("pre 2.6.0 config with a cluster that has arrays in auth config", () =>
const file = clusterStore.clustersList[0].kubeConfigPath;
const config = fs.readFileSync(file, "utf8");
const kc = yaml.safeLoad(config);
expect(kc.users[0].user["auth-provider"].config["access-token"]).toBe("should be string");
expect(kc.users[0].user["auth-provider"].config["expiry"]).toBe("should be string");
});
@ -319,8 +340,10 @@ describe("pre 2.6.0 config with a cluster icon", () => {
"icon_path": testDataIcon,
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
});
@ -330,6 +353,7 @@ describe("pre 2.6.0 config with a cluster icon", () => {
it("moves the icon into preferences", async () => {
const storedClusterData = clusterStore.clustersList[0];
expect(storedClusterData.hasOwnProperty("icon")).toBe(false);
expect(storedClusterData.preferences.hasOwnProperty("icon")).toBe(true);
expect(storedClusterData.preferences.icon.startsWith("data:;base64,")).toBe(true);
@ -356,8 +380,10 @@ describe("for a pre 2.7.0-beta.0 config without a workspace", () => {
})
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
});
@ -367,6 +393,7 @@ describe("for a pre 2.7.0-beta.0 config without a workspace", () => {
it("adds cluster to default workspace", async () => {
const storedClusterData = clusterStore.clustersList[0];
expect(storedClusterData.workspace).toBe("default");
});
});
@ -396,8 +423,10 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => {
"icon_path": testDataIcon,
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
});
@ -407,11 +436,13 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => {
it("migrates to modern format with kubeconfig in a file", async () => {
const config = clusterStore.clustersList[0].kubeConfigPath;
expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content");
});
it("migrates to modern format with icon not in file", async () => {
const { icon } = clusterStore.clustersList[0].preferences;
expect(icon.startsWith("data:;base64,")).toBe(true);
});
});

View File

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

View File

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

View File

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

View File

@ -44,7 +44,6 @@ describe("workspace store tests", () => {
it("can update workspace description", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
const workspace = ws.addWorkspace(new Workspace({
id: "foobar",
name: "foobar",
@ -65,6 +64,7 @@ describe("workspace store tests", () => {
}));
const workspace = ws.getById("123");
expect(workspace.name).toBe("foobar");
expect(workspace.enabled).toBe(true);
});

View File

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

View File

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

View File

@ -98,7 +98,9 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
static embedCustomKubeConfig(clusterId: ClusterId, kubeConfig: KubeConfig | string): string {
const filePath = ClusterStore.getCustomKubeConfigPath(clusterId);
const fileContents = typeof kubeConfig == "string" ? kubeConfig : dumpConfigYaml(kubeConfig);
saveToAppFiles(filePath, fileContents, { mode: 0o600 });
return filePath;
}
@ -127,11 +129,14 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
id: string;
state: ClusterState;
};
if (ipcRenderer) {
logger.info("[CLUSTER-STORE] requesting initial state sync");
const clusterStates: clusterStateSync[] = await requestMain(ClusterStore.stateRequestChannel);
clusterStates.forEach((clusterState) => {
const cluster = this.getById(clusterState.id);
if (cluster) {
cluster.setState(clusterState.state);
}
@ -139,12 +144,14 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
} else {
handleRequest(ClusterStore.stateRequestChannel, (): clusterStateSync[] => {
const states: clusterStateSync[] = [];
this.clustersList.forEach((cluster) => {
states.push({
state: cluster.getState(),
id: cluster.id
});
});
return states;
});
}
@ -207,6 +214,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
@action
setActive(id: ClusterId) {
const clusterId = this.clusters.has(id) ? id : null;
this.activeCluster = clusterId;
workspaceStore.setLastActiveClusterId(clusterId);
}
@ -214,11 +222,13 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
@action
swapIconOrders(workspace: WorkspaceId, from: number, to: number) {
const clusters = this.getByWorkspaceId(workspace);
if (from < 0 || to < 0 || from >= clusters.length || to >= clusters.length || isNaN(from) || isNaN(to)) {
throw new Error(`invalid from<->to arguments`);
}
move.mutate(clusters, from, to);
for (const i in clusters) {
// This resets the iconOrder to the current display order
clusters[i].preferences.iconOrder = +i;
@ -236,12 +246,14 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
getByWorkspaceId(workspaceId: string): Cluster[] {
const clusters = Array.from(this.clusters.values())
.filter(cluster => cluster.workspace === workspaceId);
return _.sortBy(clusters, cluster => cluster.preferences.iconOrder);
}
@action
addClusters(...models: ClusterModel[]): Cluster[] {
const clusters: Cluster[] = [];
models.forEach(model => {
clusters.push(this.addCluster(model));
});
@ -253,13 +265,16 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
addCluster(model: ClusterModel | Cluster): Cluster {
appEventBus.emit({ name: "cluster", action: "add" });
let cluster = model as Cluster;
if (!(model instanceof Cluster)) {
cluster = new Cluster(model);
}
if (!cluster.isManaged) {
cluster.enabled = true;
}
this.clusters.set(model.id, cluster);
return cluster;
}
@ -271,11 +286,14 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
async removeById(clusterId: ClusterId) {
appEventBus.emit({ name: "cluster", action: "remove" });
const cluster = this.getById(clusterId);
if (cluster) {
this.clusters.delete(clusterId);
if (this.activeCluster === clusterId) {
this.setActive(null);
}
// remove only custom kubeconfigs (pasted as text)
if (cluster.kubeConfigPath == ClusterStore.getCustomKubeConfigPath(clusterId)) {
unlink(cluster.kubeConfigPath).catch(() => null);
@ -299,10 +317,12 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
// update new clusters
for (const clusterModel of clusters) {
let cluster = currentClusters.get(clusterModel.id);
if (cluster) {
cluster.updateModel(clusterModel);
} else {
cluster = new Cluster(clusterModel);
if (!cluster.isManaged) {
cluster.enabled = true;
}
@ -336,6 +356,7 @@ export const clusterStore = ClusterStore.getInstance<ClusterStore>();
export function getClusterIdFromHost(hostname: string): ClusterId {
const subDomains = hostname.split(":")[0].split(".");
return subDomains.slice(-2)[0]; // e.g host == "%clusterId.localhost:45345"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -125,11 +125,14 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
id: string;
state: WorkspaceState;
};
if (ipcRenderer) {
logger.info("[WORKSPACE-STORE] requesting initial state sync");
const workspaceStates: workspaceStateSync[] = await requestMain(WorkspaceStore.stateRequestChannel);
workspaceStates.forEach((workspaceState) => {
const workspace = this.getById(workspaceState.id);
if (workspace) {
workspace.setState(workspaceState.state);
}
@ -137,12 +140,14 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
} else {
handleRequest(WorkspaceStore.stateRequestChannel, (): workspaceStateSync[] => {
const states: workspaceStateSync[] = [];
this.workspacesList.forEach((workspace) => {
states.push({
state: workspace.getState(),
id: workspace.id
});
});
return states;
});
}
@ -202,6 +207,7 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
@action
setActive(id = WorkspaceStore.defaultId) {
if (id === this.currentWorkspaceId) return;
if (!this.getById(id)) {
throw new Error(`workspace ${id} doesn't exist`);
}
@ -211,15 +217,18 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
@action
addWorkspace(workspace: Workspace) {
const { id, name } = workspace;
if (!name.trim() || this.getByName(name.trim())) {
return;
}
this.workspaces.set(id, workspace);
if (!workspace.isManaged) {
workspace.enabled = true;
}
appEventBus.emit({name: "workspace", action: "add"});
return workspace;
}
@ -237,10 +246,13 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
@action
removeWorkspaceById(id: WorkspaceId) {
const workspace = this.getById(id);
if (!workspace) return;
if (this.isDefault(id)) {
throw new Error("Cannot remove default workspace");
}
if (this.currentWorkspaceId === id) {
this.currentWorkspaceId = WorkspaceStore.defaultId; // reset to default
}
@ -259,10 +271,12 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
if (currentWorkspace) {
this.currentWorkspaceId = currentWorkspace;
}
if (workspaces.length) {
this.workspaces.clear();
workspaces.forEach(ws => {
const workspace = new Workspace(ws);
if (!workspace.isManaged) {
workspace.enabled = true;
}

View File

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

View File

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

View File

@ -25,6 +25,7 @@ export interface InstalledExtension {
}
const logModule = "[EXTENSION-DISCOVERY]";
export const manifestFilename = "package.json";
/**
@ -133,7 +134,6 @@ export class ExtensionDiscovery {
if (path.basename(filePath) === manifestFilename) {
try {
const absPath = path.dirname(filePath);
// this.loadExtensionFromPath updates this.packagesJson
const extension = await this.loadExtensionFromPath(absPath);
@ -251,6 +251,7 @@ export class ExtensionDiscovery {
manifestJson = __non_webpack_require__(manifestPath);
const installedManifestPath = path.join(this.nodeModulesPath, manifestJson.name, "package.json");
this.packagesJson.dependencies[manifestJson.name] = path.dirname(manifestPath);
const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath);
@ -272,6 +273,7 @@ export class ExtensionDiscovery {
async loadExtensions(): Promise<Map<LensExtensionId, InstalledExtension>> {
const bundledExtensions = await this.loadBundledExtensions();
const localExtensions = await this.loadFromFolder(this.localFolderPath);
await this.installPackages();
const extensions = bundledExtensions.concat(localExtensions);
@ -333,12 +335,14 @@ export class ExtensionDiscovery {
}
const extension = await this.loadExtensionFromPath(absPath);
if (extension) {
extensions.push(extension);
}
}
logger.debug(`${logModule}: ${extensions.length} extensions loaded`, { folderPath, extensions });
return extensions;
}

View File

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

View File

@ -176,6 +176,7 @@ export class ExtensionLoader {
loadOnClusterRenderer() {
logger.info(`${logModule}: load on cluster renderer (dashboard)`);
const cluster = getHostedCluster();
this.autoInitExtensions(async (extension: LensRendererExtension) => {
if (await extension.isEnabledForCluster(cluster) === false) {
return [];
@ -209,11 +210,13 @@ export class ExtensionLoader {
if (ext.isEnabled && !alreadyInit) {
try {
const LensExtensionClass = this.requireExtension(ext);
if (!LensExtensionClass) {
continue;
}
const instance = new LensExtensionClass(ext);
instance.whenEnabled(() => register(instance));
instance.enable();
this.instances.set(extId, instance);
@ -231,12 +234,14 @@ export class ExtensionLoader {
protected requireExtension(extension: InstalledExtension): LensExtensionConstructor {
let extEntrypoint = "";
try {
if (ipcRenderer && extension.manifest.renderer) {
extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.renderer));
} else if (!ipcRenderer && extension.manifest.main) {
extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.main));
}
if (extEntrypoint !== "") {
return __non_webpack_require__(extEntrypoint).default;
}

View File

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

View File

@ -30,11 +30,13 @@ export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
protected getState(extensionLoader: ExtensionLoader) {
const state: Record<LensExtensionId, LensExtensionState> = {};
return Array.from(extensionLoader.userExtensions).reduce((state, [extId, ext]) => {
state[extId] = {
enabled: ext.isEnabled,
name: ext.manifest.name,
};
return state;
}, state);
}
@ -47,6 +49,7 @@ export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
reaction(() => this.state.toJS(), extensionsState => {
extensionsState.forEach((state, extId) => {
const ext = extensionLoader.getExtension(extId);
if (ext && !ext.isBundled) {
ext.isEnabled = state.enabled;
}
@ -61,6 +64,7 @@ export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
isEnabled(extId: LensExtensionId) {
const state = this.state.get(extId);
return state && state.enabled; // by default false
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -64,6 +64,7 @@ describe("kubeconfig manager tests", () => {
preferences: {},
})
};
mockFs(mockOpts);
});
@ -86,6 +87,7 @@ describe("kubeconfig manager tests", () => {
expect(kubeConfManager.getPath()).toBe("tmp/kubeconfig-foo");
const file = await fse.readFile(kubeConfManager.getPath());
const yml = loadYaml<any>(file.toString());
expect(yml["current-context"]).toBe("minikube");
expect(yml["clusters"][0]["cluster"]["server"]).toBe(`http://127.0.0.1:${port}/foo`);
expect(yml["users"][0]["name"]).toBe("proxy");
@ -101,8 +103,8 @@ describe("kubeconfig manager tests", () => {
const contextHandler = new ContextHandler(cluster);
const port = await getFreePort();
const kubeConfManager = await KubeconfigManager.create(cluster, contextHandler, port);
const configPath = kubeConfManager.getPath();
expect(await fse.pathExists(configPath)).toBe(true);
await kubeConfManager.unlink();
expect(await fse.pathExists(configPath)).toBe(false);

View File

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

View File

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

View File

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

View File

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

View File

@ -7,30 +7,39 @@ export class DistributionDetector extends BaseClusterDetector {
public async detect() {
this.version = await this.getKubernetesVersion();
if (await this.isRancher()) {
return { value: "rancher", accuracy: 80};
}
if (this.isGKE()) {
return { value: "gke", accuracy: 80};
}
if (this.isEKS()) {
return { value: "eks", accuracy: 80};
}
if (this.isIKS()) {
return { value: "iks", accuracy: 80};
}
if (this.isAKS()) {
return { value: "aks", accuracy: 80};
}
if (this.isDigitalOcean()) {
return { value: "digitalocean", accuracy: 90};
}
if (this.isMinikube()) {
return { value: "minikube", accuracy: 80};
}
if (this.isCustom()) {
return { value: "custom", accuracy: 10};
}
return { value: "unknown", accuracy: 10};
}
@ -38,6 +47,7 @@ export class DistributionDetector extends BaseClusterDetector {
if (this.cluster.version) return this.cluster.version;
const response = await this.k8sRequest("/version");
return response.gitVersion;
}
@ -72,6 +82,7 @@ export class DistributionDetector extends BaseClusterDetector {
protected async isRancher() {
try {
const response = await this.k8sRequest("");
return response.data.find((api: any) => api?.apiVersion?.group === "meta.cattle.io") !== undefined;
} catch (e) {
return false;

View File

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

View File

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

View File

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

View File

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

View File

@ -91,6 +91,7 @@ export class Cluster implements ClusterModel, ClusterState {
@computed get prometheusPreferences(): ClusterPrometheusPreferences {
const { prometheus, prometheusProvider } = this.preferences;
return toJS({ prometheus, prometheusProvider }, {
recurseEverything: true,
});
@ -103,6 +104,7 @@ export class Cluster implements ClusterModel, ClusterState {
constructor(model: ClusterModel) {
this.updateModel(model);
const kubeconfig = this.getKubeconfig();
if (kubeconfig.getContextObject(this.contextName)) {
this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server;
}
@ -167,13 +169,16 @@ export class Cluster implements ClusterModel, ClusterState {
}
logger.info(`[CLUSTER]: activate`, this.getMeta());
await this.whenInitialized;
if (!this.eventDisposers.length) {
this.bindEvents();
}
if (this.disconnected || !this.accessible) {
await this.reconnect();
}
await this.refreshConnectionStatus();
if (this.accessible) {
await this.refreshAllowedResources();
this.isAdmin = await this.isClusterAdmin();
@ -181,11 +186,13 @@ export class Cluster implements ClusterModel, ClusterState {
this.ensureKubectl();
}
this.activated = true;
return this.pushState();
}
protected async ensureKubectl() {
this.kubeCtl = new Kubectl(this.version);
return this.kubeCtl.ensureKubectl(); // download kubectl in background, so it's not blocking dashboard
}
@ -215,9 +222,11 @@ export class Cluster implements ClusterModel, ClusterState {
logger.info(`[CLUSTER]: refresh`, this.getMeta());
await this.whenInitialized;
await this.refreshConnectionStatus();
if (this.accessible) {
this.isAdmin = await this.isClusterAdmin();
await this.refreshAllowedResources();
if (opts.refreshMetadata) {
this.refreshMetadata();
}
@ -231,12 +240,14 @@ export class Cluster implements ClusterModel, ClusterState {
logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta());
const metadata = await detectorRegistry.detectForCluster(this);
const existingMetadata = this.metadata;
this.metadata = Object.assign(existingMetadata, metadata);
}
@action
async refreshConnectionStatus() {
const connectionStatus = await this.getConnectionStatus();
this.online = connectionStatus > ClusterStatus.Offline;
this.accessible = connectionStatus == ClusterStatus.AccessGranted;
}
@ -271,6 +282,7 @@ export class Cluster implements ClusterModel, ClusterState {
getMetrics(prometheusPath: string, queryParams: IMetricsReqParams & { query: string }) {
const prometheusPrefix = this.preferences.prometheus?.prefix || "";
const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`;
return this.k8sRequest(metricsPath, {
timeout: 0,
resolveWithFullResponse: false,
@ -283,43 +295,54 @@ export class Cluster implements ClusterModel, ClusterState {
try {
const versionDetector = new VersionDetector(this);
const versionData = await versionDetector.detect();
this.metadata.version = versionData.value;
return ClusterStatus.AccessGranted;
} catch (error) {
logger.error(`Failed to connect cluster "${this.contextName}": ${error}`);
if (error.statusCode) {
if (error.statusCode >= 400 && error.statusCode < 500) {
this.failureReason = "Invalid credentials";
return ClusterStatus.AccessDenied;
} else {
this.failureReason = error.error || error.message;
return ClusterStatus.Offline;
}
} else if (error.failed === true) {
if (error.timedOut === true) {
this.failureReason = "Connection timed out";
return ClusterStatus.Offline;
} else {
this.failureReason = "Failed to fetch credentials";
return ClusterStatus.AccessDenied;
}
}
this.failureReason = error.message;
return ClusterStatus.Offline;
}
}
async canI(resourceAttributes: V1ResourceAttributes): Promise<boolean> {
const authApi = this.getProxyKubeconfig().makeApiClient(AuthorizationV1Api);
try {
const accessReview = await authApi.createSelfSubjectAccessReview({
apiVersion: "authorization.k8s.io/v1",
kind: "SelfSubjectAccessReview",
spec: { resourceAttributes }
});
return accessReview.body.status.allowed;
} catch (error) {
logger.error(`failed to request selfSubjectAccessReview: ${error}`);
return false;
}
}
@ -343,6 +366,7 @@ export class Cluster implements ClusterModel, ClusterState {
ownerRef: this.ownerRef,
accessibleNamespaces: this.accessibleNamespaces,
};
return toJS(model, {
recurseEverything: true
});
@ -363,6 +387,7 @@ export class Cluster implements ClusterModel, ClusterState {
allowedNamespaces: this.allowedNamespaces,
allowedResources: this.allowedResources,
};
return toJS(state, {
recurseEverything: true
});
@ -397,6 +422,7 @@ export class Cluster implements ClusterModel, ClusterState {
}
const api = this.getProxyKubeconfig().makeApiClient(CoreV1Api);
try {
const namespaceList = await api.listNamespace();
const nsAccessStatuses = await Promise.all(
@ -406,12 +432,15 @@ export class Cluster implements ClusterModel, ClusterState {
verb: "list",
}))
);
return namespaceList.body.items
.filter((ns, i) => nsAccessStatuses[i])
.map(ns => ns.metadata.name);
} catch (error) {
const ctx = this.getProxyKubeconfig().getContextObject(this.contextName);
if (ctx.namespace) return [ctx.namespace];
return [];
}
}
@ -429,6 +458,7 @@ export class Cluster implements ClusterModel, ClusterState {
namespace: this.allowedNamespaces[0]
}))
);
return apiResources
.filter((resource, i) => resourceAccessStatuses[i])
.map(apiResource => apiResource.resource);

View File

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

View File

@ -8,6 +8,7 @@ import logger from "./logger";
export function exitApp() {
const windowManager = WindowManager.getInstance<WindowManager>();
const clusterManager = ClusterManager.getInstance<ClusterManager>();
appEventBus.emit({ name: "service", action: "close" });
windowManager.hide();
clusterManager.stop();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -45,6 +45,7 @@ export class KubeAuthProxy {
"--accept-hosts", this.acceptHosts,
"--reject-paths", "^[^/]"
];
if (process.env.DEBUG_PROXY === "true") {
args.push("-v", "9");
}
@ -62,6 +63,7 @@ export class KubeAuthProxy {
this.proxyProcess.stdout.on("data", (data) => {
let logItem = data.toString();
if (logItem.startsWith("Starting to serve on")) {
logItem = "Authentication proxy started\n";
}
@ -80,19 +82,23 @@ export class KubeAuthProxy {
const error = data.split("http: proxy error:").slice(1).join("").trim();
let errorMsg = error;
const jsonError = error.split("Response: ")[1];
if (jsonError) {
try {
const parsedError = JSON.parse(jsonError);
errorMsg = parsedError.error_description || parsedError.error || jsonError;
} catch (_) {
errorMsg = jsonError.trim();
}
}
return errorMsg;
}
protected async sendIpcLogMessage(res: KubeAuthProxyLog) {
const channel = `kube-auth:${this.cluster.id}`;
logger.info(`[KUBE-AUTH]: out-channel "${channel}"`, { ...res, meta: this.cluster.getMeta() });
broadcastMessage(channel, res);
}

View File

@ -15,7 +15,9 @@ export class KubeconfigManager {
static async create(cluster: Cluster, contextHandler: ContextHandler, port: number) {
const kcm = new KubeconfigManager(cluster, contextHandler, port);
await kcm.init();
return kcm;
}
@ -66,13 +68,14 @@ export class KubeconfigManager {
}
]
};
// write
const configYaml = dumpConfigYaml(proxyConfig);
fs.ensureDir(path.dirname(tempFile));
fs.writeFileSync(tempFile, configYaml, { mode: 0o600 });
this.tempFile = tempFile;
logger.debug(`Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`);
return tempFile;
}

View File

@ -27,12 +27,10 @@ const kubectlMap: Map<string, string> = new Map([
["1.18", "1.18.8"],
["1.19", "1.19.0"]
]);
const packageMirrors: Map<string, string> = new Map([
["default", "https://storage.googleapis.com/kubernetes-release/release"],
["china", "https://mirror.azure.cn/kubernetes/kubectl"]
]);
let bundledPath: string;
const initScriptVersionString = "# lens-initscript v3\n";
@ -41,6 +39,7 @@ export function bundledKubectlPath(): string {
if (isDevelopment || isTestEnv) {
const platformName = isWindows ? "windows" : process.platform;
bundledPath = path.join(process.cwd(), "binaries", "client", platformName, process.arch, "kubectl");
} else {
bundledPath = path.join(process.resourcesPath, process.arch, "kubectl");
@ -71,12 +70,14 @@ export class Kubectl {
// Returns the single bundled Kubectl instance
public static bundled() {
if (!Kubectl.bundledInstance) Kubectl.bundledInstance = new Kubectl(Kubectl.bundledKubectlVersion);
return Kubectl.bundledInstance;
}
constructor(clusterVersion: string) {
const versionParts = /^v?(\d+\.\d+)(.*)/.exec(clusterVersion);
const minorVersion = versionParts[1];
/* minorVersion is the first two digits of kube server version
if the version map includes that, use that version, if not, fallback to the exact x.y.z of kube version */
if (kubectlMap.has(minorVersion)) {
@ -134,18 +135,22 @@ export class Kubectl {
// return binary name if bundled path is not functional
if (!await this.checkBinary(this.getBundledPath(), false)) {
Kubectl.invalidBundle = true;
return path.basename(this.getBundledPath());
}
try {
if (!await this.ensureKubectl()) {
logger.error("Failed to ensure kubectl, fallback to the bundled version");
return this.getBundledPath();
}
return this.path;
} catch (err) {
logger.error("Failed to ensure kubectl, fallback to the bundled version");
logger.error(err);
return this.getBundledPath();
}
}
@ -154,28 +159,35 @@ export class Kubectl {
try {
await this.ensureKubectl();
await this.writeInitScripts();
return this.dirname;
} catch (err) {
logger.error(err);
return "";
}
}
public async checkBinary(path: string, checkVersion = true) {
const exists = await pathExists(path);
if (exists) {
try {
const { stdout } = await promiseExec(`"${path}" version --client=true -o json`);
const output = JSON.parse(stdout);
if (!checkVersion) {
return true;
}
let version: string = output.clientVersion.gitVersion;
if (version[0] === "v") {
version = version.slice(1);
}
if (version === this.kubectlVersion) {
logger.debug(`Local kubectl is version ${this.kubectlVersion}`);
return true;
}
logger.error(`Local kubectl is version ${version}, expected ${this.kubectlVersion}, unlinking`);
@ -184,6 +196,7 @@ export class Kubectl {
}
await fs.promises.unlink(this.path);
}
return false;
}
@ -191,13 +204,16 @@ export class Kubectl {
if (this.kubectlVersion === Kubectl.bundledKubectlVersion) {
try {
const exist = await pathExists(this.path);
if (!exist) {
await fs.promises.copyFile(this.getBundledPath(), this.path);
await fs.promises.chmod(this.path, 0o755);
}
return true;
} catch (err) {
logger.error(`Could not copy the bundled kubectl to app-data: ${err}`);
return false;
}
} else {
@ -209,35 +225,44 @@ export class Kubectl {
if (userStore.preferences?.downloadKubectlBinaries === false) {
return true;
}
if (Kubectl.invalidBundle) {
logger.error(`Detected invalid bundle binary, returning ...`);
return false;
}
await ensureDir(this.dirname, 0o755);
return lockFile.lock(this.dirname).then(async (release) => {
logger.debug(`Acquired a lock for ${this.kubectlVersion}`);
const bundled = await this.checkBundled();
let isValid = await this.checkBinary(this.path, !bundled);
if (!isValid && !bundled) {
await this.downloadKubectl().catch((error) => {
logger.error(error);
logger.debug(`Releasing lock for ${this.kubectlVersion}`);
release();
return false;
});
isValid = !await this.checkBinary(this.path, false);
}
if (!isValid) {
logger.debug(`Releasing lock for ${this.kubectlVersion}`);
release();
return false;
}
logger.debug(`Releasing lock for ${this.kubectlVersion}`);
release();
return true;
}).catch((e) => {
logger.error(`Failed to get a lock for ${this.kubectlVersion}`);
logger.error(e);
return false;
});
}
@ -246,12 +271,14 @@ export class Kubectl {
await ensureDir(path.dirname(this.path), 0o755);
logger.info(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`);
return new Promise((resolve, reject) => {
const stream = customRequest({
url: this.url,
gzip: true,
});
const file = fs.createWriteStream(this.path);
stream.on("complete", () => {
logger.debug("kubectl binary download finished");
file.end();
@ -279,8 +306,8 @@ export class Kubectl {
const helmPath = helmCli.getBinaryDir();
const fsPromises = fs.promises;
const bashScriptPath = path.join(this.dirname, ".bash_set_path");
let bashScript = `${initScriptVersionString}`;
bashScript += "tempkubeconfig=\"$KUBECONFIG\"\n";
bashScript += "test -f \"/etc/profile\" && . \"/etc/profile\"\n";
bashScript += "if test -f \"$HOME/.bash_profile\"; then\n";
@ -302,7 +329,6 @@ export class Kubectl {
await fsPromises.writeFile(bashScriptPath, bashScript.toString(), { mode: 0o644 });
const zshScriptPath = path.join(this.dirname, ".zlogin");
let zshScript = `${initScriptVersionString}`;
zshScript += "tempkubeconfig=\"$KUBECONFIG\"\n";
@ -335,9 +361,11 @@ export class Kubectl {
protected getDownloadMirror() {
const mirror = packageMirrors.get(userStore.preferences?.downloadMirror);
if (mirror) {
return mirror;
}
return packageMirrors.get("default"); // MacOS packages are only available from default
}
}

View File

@ -8,12 +8,14 @@ jest.mock("../common/user-store");
describe("kubectlVersion", () => {
it("returns bundled version if exactly same version used", async () => {
const kubectl = new Kubectl(Kubectl.bundled().kubectlVersion);
expect(kubectl.kubectlVersion).toBe(Kubectl.bundled().kubectlVersion);
});
it("returns bundled version if same major.minor version is used", async () => {
const { bundledKubectlVersion } = packageInfo.config;
const kubectl = new Kubectl(bundledKubectlVersion);
expect(kubectl.kubectlVersion).toBe(Kubectl.bundled().kubectlVersion);
});
});
@ -24,19 +26,23 @@ describe("getPath()", () => {
const kubectl = new Kubectl(bundledKubectlVersion);
const kubectlPath = await kubectl.getPath();
let binaryName = "kubectl";
if (isWindows) {
binaryName += ".exe";
}
const expectedPath = path.join(Kubectl.kubectlDir, Kubectl.bundledKubectlVersion, binaryName);
expect(kubectlPath).toBe(expectedPath);
});
it("returns plain binary name if bundled kubectl is non-functional", async () => {
const { bundledKubectlVersion } = packageInfo.config;
const kubectl = new Kubectl(bundledKubectlVersion);
jest.spyOn(kubectl, "getBundledPath").mockReturnValue("/invalid/path/kubectl");
const kubectlPath = await kubectl.getPath();
let binaryName = "kubectl";
if (isWindows) {
binaryName += ".exe";
}

View File

@ -31,6 +31,7 @@ export class LensBinary {
constructor(opts: LensBinaryOpts) {
const baseDir = opts.baseDir;
this.originalBinaryName = opts.originalBinaryName;
this.binaryName = opts.newBinaryName || opts.originalBinaryName;
this.binaryVersion = opts.version;
@ -50,11 +51,13 @@ export class LensBinary {
this.arch = arch;
this.platformName = isWindows ? "windows" : process.platform;
this.dirname = path.normalize(path.join(baseDir, this.binaryName));
if (isWindows) {
this.binaryName = `${this.binaryName}.exe`;
this.originalBinaryName = `${this.originalBinaryName}.exe`;
}
const tarName = this.getTarName();
if (tarName) {
this.tarPath = path.join(this.dirname, tarName);
}
@ -70,6 +73,7 @@ export class LensBinary {
public async binaryPath() {
await this.ensureBinary();
return this.getBinaryPath();
}
@ -96,20 +100,24 @@ export class LensBinary {
public async binDir() {
try {
await this.ensureBinary();
return this.dirname;
} catch (err) {
this.logger.error(err);
return "";
}
}
protected async checkBinary() {
const exists = await pathExists(this.getBinaryPath());
return exists;
}
public async ensureBinary() {
const isValid = await this.checkBinary();
if (!isValid) {
await this.downloadBinary().catch((error) => {
this.logger.error(error);
@ -148,6 +156,7 @@ export class LensBinary {
protected async downloadBinary() {
const binaryPath = this.tarPath || this.getBinaryPath();
await ensureDir(this.getBinaryDir(), 0o755);
const file = fs.createWriteStream(binaryPath);
@ -159,7 +168,6 @@ export class LensBinary {
gzip: true,
...this.requestOpts
};
const stream = request(requestOpts);
stream.on("complete", () => {
@ -174,6 +182,7 @@ export class LensBinary {
});
throw(error);
});
return new Promise((resolve, reject) => {
file.on("close", () => {
this.logger.debug(`${this.originalBinaryName} binary download closed`);

View File

@ -30,6 +30,7 @@ export class LensProxy {
listen(port = this.port): this {
this.proxyServer = this.buildCustomProxy().listen(port);
logger.info(`LensProxy server has started at ${this.origin}`);
return this;
}
@ -49,6 +50,7 @@ export class LensProxy {
}, (req: http.IncomingMessage, res: http.ServerResponse) => {
this.handleRequest(proxy, req, res);
});
spdyProxy.on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => {
if (req.url.startsWith(`${apiPrefix}?`)) {
this.handleWsUpgrade(req, socket, head);
@ -59,22 +61,27 @@ export class LensProxy {
spdyProxy.on("error", (err) => {
logger.error("proxy error", err);
});
return spdyProxy;
}
protected async handleProxyUpgrade(proxy: httpProxy, req: http.IncomingMessage, socket: net.Socket, head: Buffer) {
const cluster = this.clusterManager.getClusterForRequest(req);
if (cluster) {
const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, "");
const apiUrl = url.parse(cluster.apiUrl);
const pUrl = url.parse(proxyUrl);
const connectOpts = { port: parseInt(pUrl.port), host: pUrl.hostname };
const proxySocket = new net.Socket();
proxySocket.connect(connectOpts, () => {
proxySocket.write(`${req.method} ${pUrl.path} HTTP/1.1\r\n`);
proxySocket.write(`Host: ${apiUrl.host}\r\n`);
for (let i = 0; i < req.rawHeaders.length; i += 2) {
const key = req.rawHeaders[i];
if (key !== "Host" && key !== "Authorization") {
proxySocket.write(`${req.rawHeaders[i]}: ${req.rawHeaders[i+1]}\r\n`);
}
@ -112,16 +119,20 @@ export class LensProxy {
protected createProxy(): httpProxy {
const proxy = httpProxy.createProxyServer();
proxy.on("error", (error, req, res, target) => {
if (this.closed) {
return;
}
if (target) {
logger.debug(`Failed proxy to target: ${JSON.stringify(target, null, 2)}`);
if (req.method === "GET" && (!res.statusCode || res.statusCode >= 500)) {
const reqId = this.getRequestId(req);
const retryCount = this.retryCounters.get(reqId) || 0;
const timeoutMs = retryCount * 250;
if (retryCount < 20) {
logger.debug(`Retrying proxy request to url: ${reqId}`);
setTimeout(() => {
@ -131,6 +142,7 @@ export class LensProxy {
}
}
}
try {
res.writeHead(500).end("Oops, something went wrong.");
} catch (e) {
@ -143,9 +155,11 @@ export class LensProxy {
protected createWsListener(): WebSocket.Server {
const ws = new WebSocket.Server({ noServer: true });
return ws.on("connection", ((socket: WebSocket, req: http.IncomingMessage) => {
const cluster = this.clusterManager.getClusterForRequest(req);
const nodeParam = url.parse(req.url, true).query["node"]?.toString();
openShell(socket, cluster, nodeParam);
}));
}
@ -155,6 +169,7 @@ export class LensProxy {
delete req.headers.authorization;
req.url = req.url.replace(apiKubePrefix, "");
const isWatchRequest = req.url.includes("watch=");
return await contextHandler.getApiTarget(isWatchRequest);
}
}
@ -165,11 +180,14 @@ export class LensProxy {
protected async handleRequest(proxy: httpProxy, req: http.IncomingMessage, res: http.ServerResponse) {
const cluster = this.clusterManager.getClusterForRequest(req);
if (cluster) {
const proxyTarget = await this.getProxyTarget(req, cluster.contextHandler);
if (proxyTarget) {
// allow to fetch apis in "clusterId.localhost:port" from "localhost:port"
res.setHeader("Access-Control-Allow-Origin", this.origin);
return proxy.web(req, res, proxyTarget);
}
}
@ -178,6 +196,7 @@ export class LensProxy {
protected async handleWsUpgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer) {
const wsServer = this.createWsListener();
wsServer.handleUpgrade(req, socket, head, (con) => {
wsServer.emit("connection", con, req);
});

View File

@ -3,12 +3,10 @@ import winston from "winston";
import { isDebugging } from "../common/vars";
const logLevel = process.env.LOG_LEVEL ? process.env.LOG_LEVEL : isDebugging ? "debug" : "info";
const consoleOptions: winston.transports.ConsoleTransportOptions = {
handleExceptions: false,
level: logLevel,
};
const fileOptions: winston.transports.FileTransportOptions = {
handleExceptions: false,
level: logLevel,
@ -18,7 +16,6 @@ const fileOptions: winston.transports.FileTransportOptions = {
maxFiles: 16,
tailable: true,
};
const logger = winston.createLogger({
format: winston.format.combine(
winston.format.colorize(),

View File

@ -27,6 +27,7 @@ export function showAbout(browserWindow: BrowserWindow) {
`Node: ${process.versions.node}`,
`Copyright 2020 Mirantis, Inc.`,
];
dialog.showMessageBoxSync(browserWindow, {
title: `${isWindows ? " ".repeat(2) : ""}${appName}`,
type: "info",
@ -39,6 +40,7 @@ export function showAbout(browserWindow: BrowserWindow) {
export function buildMenu(windowManager: WindowManager) {
function ignoreOnMac(menuItems: MenuItemConstructorOptions[]) {
if (isMac) return [];
return menuItems;
}
@ -48,6 +50,7 @@ export function buildMenu(windowManager: WindowManager) {
item.enabled = false;
});
}
return menuItems;
}
@ -96,7 +99,6 @@ export function buildMenu(windowManager: WindowManager) {
}
]
};
const fileMenu: MenuItemConstructorOptions = {
label: "File",
submenu: [
@ -154,7 +156,6 @@ export function buildMenu(windowManager: WindowManager) {
])
]
};
const editMenu: MenuItemConstructorOptions = {
label: "Edit",
submenu: [
@ -169,7 +170,6 @@ export function buildMenu(windowManager: WindowManager) {
{ role: "selectAll" },
]
};
const viewMenu: MenuItemConstructorOptions = {
label: "View",
submenu: [
@ -203,7 +203,6 @@ export function buildMenu(windowManager: WindowManager) {
{ role: "togglefullscreen" }
]
};
const helpMenu: MenuItemConstructorOptions = {
role: "help",
submenu: [
@ -235,7 +234,6 @@ export function buildMenu(windowManager: WindowManager) {
])
]
};
// Prepare menu items order
const appMenu: Record<MenuTopId, MenuItemConstructorOptions> = {
mac: macAppMenu,
@ -249,6 +247,7 @@ export function buildMenu(windowManager: WindowManager) {
menuRegistry.getItems().forEach(({ parentId, ...menuItem }) => {
try {
const topMenu = appMenu[parentId as MenuTopId].submenu as MenuItemConstructorOptions[];
topMenu.push(menuItem);
} catch (err) {
logger.error(`[MENU]: can't register menu item, parentId=${parentId}`, { menuItem });
@ -260,6 +259,7 @@ export function buildMenu(windowManager: WindowManager) {
}
const menu = Menu.buildFromTemplate(Object.values(appMenu));
Menu.setApplicationMenu(menu);
if (isTestEnv) {
@ -273,6 +273,7 @@ export function buildMenu(windowManager: WindowManager) {
for (const name of names) {
parentLabels.push(name);
menuItem = menu?.items?.find(item => item.label === name);
if (!menuItem) {
break;
}
@ -280,14 +281,18 @@ export function buildMenu(windowManager: WindowManager) {
}
const menuPath: string = parentLabels.join(" -> ");
if (!menuItem) {
logger.info(`[MENU:test-menu-item-click] Cannot find menu item ${menuPath}`);
return;
}
const { enabled, visible, click } = menuItem;
if (enabled === false || visible === false || typeof click !== "function") {
logger.info(`[MENU:test-menu-item-click] Menu item ${menuPath} not clickable`);
return;
}

View File

@ -23,6 +23,7 @@ export class NodeShellSession extends ShellSession {
public async open() {
const shell = await this.kubectl.getPath();
let args = [];
if (this.createNodeShellPod(this.podId, this.nodeName)) {
await this.waitForRunningPod(this.podId).catch(() => {
this.exit(1001);
@ -31,6 +32,7 @@ export class NodeShellSession extends ShellSession {
args = ["exec", "-i", "-t", "-n", "kube-system", this.podId, "--", "sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"];
const shellEnv = await this.getCachedShellEnv();
this.shellProcess = pty.spawn(shell, args, {
cols: 80,
cwd: this.cwd() || shellEnv["HOME"],
@ -85,10 +87,13 @@ export class NodeShellSession extends ShellSession {
}
}
} as k8s.V1Pod;
await k8sApi.createNamespacedPod("kube-system", pod).catch((error) => {
logger.error(error);
return false;
});
return true;
}
@ -98,6 +103,7 @@ export class NodeShellSession extends ShellSession {
}
this.kc = new k8s.KubeConfig();
this.kc.loadFromFile(this.kubeconfigPath);
return this.kc;
}
@ -105,7 +111,6 @@ export class NodeShellSession extends ShellSession {
return new Promise<boolean>(async (resolve, reject) => {
const kc = this.getKubeConfig();
const watch = new k8s.Watch(kc);
const req = await watch.watch(`/api/v1/namespaces/kube-system/pods`, {},
// callback is called for each received object.
(type, obj) => {
@ -119,6 +124,7 @@ export class NodeShellSession extends ShellSession {
reject(false);
}
);
setTimeout(() => {
req.abort();
reject(false);
@ -129,17 +135,20 @@ export class NodeShellSession extends ShellSession {
protected deleteNodeShellPod() {
const kc = this.getKubeConfig();
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
k8sApi.deleteNamespacedPod(this.podId, "kube-system");
}
}
export async function openShell(socket: WebSocket, cluster: Cluster, nodeName?: string): Promise<ShellSession> {
let shell: ShellSession;
if (nodeName) {
shell = new NodeShellSession(socket, cluster, nodeName);
} else {
shell = new ShellSession(socket, cluster);
}
shell.open();
return shell;
}

View File

@ -5,11 +5,14 @@ import logger from "./logger";
export async function getFreePort(): Promise<number> {
logger.debug("Lookup new free port..");
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("listening", () => {
const port = (server.address() as AddressInfo).port;
server.close(() => resolve(port));
logger.debug(`New port found: ${port}`);
});

View File

@ -9,10 +9,12 @@ jest.mock("net", () => {
return new class MockServer extends EventEmitter {
listen = jest.fn(() => {
this.emit("listening");
return this;
});
address = () => {
newPort = Math.round(Math.random() * 10000);
return {
port: newPort
};

View File

@ -10,9 +10,11 @@ export class PrometheusHelm extends PrometheusLens {
public async getPrometheusService(client: CoreV1Api): Promise<PrometheusService> {
const labelSelector = "app=prometheus,component=server,heritage=Helm";
try {
const serviceList = await client.listServiceForAllNamespaces(false, "", null, labelSelector);
const service = serviceList.body.items[0];
if (!service) return;
return {
@ -23,6 +25,7 @@ export class PrometheusHelm extends PrometheusLens {
};
} catch(error) {
logger.warn(`PrometheusHelm: failed to list services: ${error.toString()}`);
return;
}
}

View File

@ -11,6 +11,7 @@ export class PrometheusLens implements PrometheusProvider {
try {
const resp = await client.readNamespacedService("prometheus", "lens-metrics");
const service = resp.body;
return {
id: this.id,
namespace: service.metadata.namespace,
@ -72,6 +73,7 @@ export class PrometheusLens implements PrometheusProvider {
case "ingress":
const bytesSent = (ingress: string, statuses: string) =>
`sum(rate(nginx_ingress_controller_bytes_sent_sum{ingress="${ingress}", status=~"${statuses}"}[${this.rateAccuracy}])) by (ingress)`;
return {
bytesSentSuccess: bytesSent(opts.igress, "^2\\\\d*"),
bytesSentFailure: bytesSent(opts.ingres, "^5\\\\d*"),

View File

@ -10,9 +10,11 @@ export class PrometheusOperator implements PrometheusProvider {
public async getPrometheusService(client: CoreV1Api): Promise<PrometheusService> {
try {
let service: V1Service;
for (const labelSelector of ["operated-prometheus=true", "self-monitor=true"]) {
if (!service) {
const serviceList = await client.listServiceForAllNamespaces(null, null, null, labelSelector);
service = serviceList.body.items[0];
}
}
@ -26,6 +28,7 @@ export class PrometheusOperator implements PrometheusProvider {
};
} catch(error) {
logger.warn(`PrometheusOperator: failed to list services: ${error.toString()}`);
return;
}
}
@ -80,6 +83,7 @@ export class PrometheusOperator implements PrometheusProvider {
case "ingress":
const bytesSent = (ingress: string, statuses: string) =>
`sum(rate(nginx_ingress_controller_bytes_sent_sum{ingress="${ingress}", status=~"${statuses}"}[${this.rateAccuracy}])) by (ingress)`;
return {
bytesSentSuccess: bytesSent(opts.igress, "^2\\\\d*"),
bytesSentFailure: bytesSent(opts.ingres, "^5\\\\d*"),

View File

@ -77,6 +77,7 @@ export class PrometheusProviderRegistry {
if (!this.prometheusProviders[type]) {
throw "Unknown Prometheus provider";
}
return this.prometheusProviders[type];
}

View File

@ -11,6 +11,7 @@ export class PrometheusStacklight implements PrometheusProvider {
try {
const resp = await client.readNamespacedService("prometheus-server", "stacklight");
const service = resp.body;
return {
id: this.id,
namespace: service.metadata.namespace,
@ -72,6 +73,7 @@ export class PrometheusStacklight implements PrometheusProvider {
case "ingress":
const bytesSent = (ingress: string, statuses: string) =>
`sum(rate(nginx_ingress_controller_bytes_sent_sum{ingress="${ingress}", status=~"${statuses}"}[${this.rateAccuracy}])) by (ingress)`;
return {
bytesSentSuccess: bytesSent(opts.igress, "^2\\\\d*"),
bytesSentFailure: bytesSent(opts.ingres, "^5\\\\d*"),

View File

@ -16,19 +16,24 @@ export class ResourceApplier {
async apply(resource: KubernetesObject | any): Promise<string> {
resource = this.sanitizeObject(resource);
appEventBus.emit({name: "resource", action: "apply"});
return await this.kubectlApply(yaml.safeDump(resource));
}
protected async kubectlApply(content: string): Promise<string> {
const { kubeCtl } = this.cluster;
const kubectlPath = await kubeCtl.getPath();
return new Promise<string>((resolve, reject) => {
const fileName = tempy.file({ name: "resource.yaml" });
fs.writeFileSync(fileName, content);
const cmd = `"${kubectlPath}" apply --kubeconfig "${this.cluster.getProxyKubeconfigPath()}" -o json -f "${fileName}"`;
logger.debug(`shooting manifests with: ${cmd}`);
const execEnv: NodeJS.ProcessEnv = Object.assign({}, process.env);
const httpsProxy = this.cluster.preferences?.httpsProxy;
if (httpsProxy) {
execEnv["HTTPS_PROXY"] = httpsProxy;
}
@ -37,6 +42,7 @@ export class ResourceApplier {
if (stderr != "") {
fs.unlinkSync(fileName);
reject(stderr);
return;
}
fs.unlinkSync(fileName);
@ -48,20 +54,25 @@ export class ResourceApplier {
public async kubectlApplyAll(resources: string[]): Promise<string> {
const { kubeCtl } = this.cluster;
const kubectlPath = await kubeCtl.getPath();
return new Promise((resolve, reject) => {
const tmpDir = tempy.directory();
// Dump each resource into tmpDir
resources.forEach((resource, index) => {
fs.writeFileSync(path.join(tmpDir, `${index}.yaml`), resource);
});
const cmd = `"${kubectlPath}" apply --kubeconfig "${this.cluster.getProxyKubeconfigPath()}" -o json -f "${tmpDir}"`;
console.log("shooting manifests with:", cmd);
exec(cmd, (error, stdout, stderr) => {
if (error) {
reject(`Error applying manifests:${error}`);
}
if (stderr != "") {
reject(stderr);
return;
}
resolve(stdout);
@ -74,9 +85,11 @@ export class ResourceApplier {
delete resource.status;
delete resource.metadata?.resourceVersion;
const annotations = resource.metadata?.annotations;
if (annotations) {
delete annotations["kubectl.kubernetes.io/last-applied-configuration"];
}
return resource;
}
}

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