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

Allow user to configure kubectl binary preferences (#800)

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>

Co-authored-by: Alex Andreev <alex.andreev.email@gmail.com>
This commit is contained in:
Lauri Nevala 2020-09-04 22:50:50 +03:00 committed by GitHub
parent 8f94f4a3da
commit 4250523fe4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 185 additions and 88 deletions

View File

@ -1,4 +1,5 @@
import type { ThemeId } from "../renderer/theme.store"; import type { ThemeId } from "../renderer/theme.store";
import { app, remote } from 'electron';
import semver from "semver" import semver from "semver"
import { readFile } from "fs-extra" import { readFile } from "fs-extra"
import { action, observable, reaction, toJS } from "mobx"; import { action, observable, reaction, toJS } from "mobx";
@ -8,6 +9,7 @@ import { getAppVersion } from "./utils/app-version";
import { kubeConfigDefaultPath, loadConfig } from "./kube-helpers"; import { kubeConfigDefaultPath, loadConfig } from "./kube-helpers";
import { tracker } from "./tracker"; import { tracker } from "./tracker";
import logger from "../main/logger"; import logger from "../main/logger";
import path from 'path';
export interface UserStoreModel { export interface UserStoreModel {
kubeConfigPath: string; kubeConfigPath: string;
@ -22,6 +24,9 @@ export interface UserPreferences {
allowUntrustedCAs?: boolean; allowUntrustedCAs?: boolean;
allowTelemetry?: boolean; allowTelemetry?: boolean;
downloadMirror?: string | "default"; downloadMirror?: string | "default";
downloadKubectlBinaries?: boolean;
downloadBinariesPath?: string;
kubectlBinariesPath?: string;
} }
export class UserStore extends BaseStore<UserStoreModel> { export class UserStore extends BaseStore<UserStoreModel> {
@ -53,6 +58,9 @@ export class UserStore extends BaseStore<UserStoreModel> {
allowUntrustedCAs: false, allowUntrustedCAs: false,
colorTheme: UserStore.defaultTheme, colorTheme: UserStore.defaultTheme,
downloadMirror: "default", downloadMirror: "default",
downloadKubectlBinaries: true, // Download kubectl binaries matching cluster version
downloadBinariesPath: this.getDefaultKubectlPath(),
kubectlBinariesPath: ""
}; };
get isNewVersion() { get isNewVersion() {
@ -98,6 +106,14 @@ export class UserStore extends BaseStore<UserStoreModel> {
this.newContexts.clear(); this.newContexts.clear();
} }
/**
* Getting default directory to download kubectl binaries
* @returns string
*/
getDefaultKubectlPath(): string {
return path.join((app || remote.app).getPath("userData"), "binaries")
}
@action @action
protected async fromStore(data: Partial<UserStoreModel> = {}) { protected async fromStore(data: Partial<UserStoreModel> = {}) {
const { lastSeenAppVersion, seenContexts = [], preferences, kubeConfigPath } = data const { lastSeenAppVersion, seenContexts = [], preferences, kubeConfigPath } = data

View File

@ -97,7 +97,7 @@ export class Kubectl {
this.url = `${this.getDownloadMirror()}/v${this.kubectlVersion}/bin/${platformName}/${arch}/${binaryName}` this.url = `${this.getDownloadMirror()}/v${this.kubectlVersion}/bin/${platformName}/${arch}/${binaryName}`
this.dirname = path.normalize(path.join(Kubectl.kubectlDir, this.kubectlVersion)) this.dirname = path.normalize(path.join(this.getDownloadDir(), this.kubectlVersion))
this.path = path.join(this.dirname, binaryName) this.path = path.join(this.dirname, binaryName)
} }
@ -105,7 +105,19 @@ export class Kubectl {
return Kubectl.bundledKubectlPath return Kubectl.bundledKubectlPath
} }
public getPathFromPreferences() {
return userStore.preferences?.kubectlBinariesPath || this.getBundledPath()
}
protected getDownloadDir() {
return userStore.preferences?.downloadBinariesPath || Kubectl.kubectlDir
}
public async getPath(bundled = false): Promise<string> { public async getPath(bundled = false): Promise<string> {
if (userStore.preferences?.downloadKubectlBinaries === false) {
return this.getPathFromPreferences()
}
// return binary name if bundled path is not functional // return binary name if bundled path is not functional
if (!await this.checkBinary(this.getBundledPath(), false)) { if (!await this.checkBinary(this.getBundledPath(), false)) {
Kubectl.invalidBundle = true Kubectl.invalidBundle = true
@ -128,6 +140,7 @@ export class Kubectl {
public async binDir() { public async binDir() {
try { try {
await this.ensureKubectl() await this.ensureKubectl()
await this.writeInitScripts()
return this.dirname return this.dirname
} catch (err) { } catch (err) {
logger.error(err) logger.error(err)
@ -180,6 +193,9 @@ export class Kubectl {
} }
public async ensureKubectl(): Promise<boolean> { public async ensureKubectl(): Promise<boolean> {
if (userStore.preferences?.downloadKubectlBinaries === false) {
return true
}
if (Kubectl.invalidBundle) { if (Kubectl.invalidBundle) {
logger.error(`Detected invalid bundle binary, returning ...`) logger.error(`Detected invalid bundle binary, returning ...`)
return false return false
@ -203,10 +219,6 @@ export class Kubectl {
release() release()
return false return false
} }
await this.writeInitScripts().catch((error) => {
logger.error("Failed to write init scripts");
logger.error(error)
})
logger.debug(`Releasing lock for ${this.kubectlVersion}`) logger.debug(`Releasing lock for ${this.kubectlVersion}`)
release() release()
return true return true
@ -249,71 +261,52 @@ export class Kubectl {
}) })
} }
protected async scriptIsLatest(scriptPath: string) {
const scriptExists = await pathExists(scriptPath)
if (!scriptExists) return false
try {
const filehandle = await fs.promises.open(scriptPath, 'r')
const buffer = Buffer.alloc(40)
await filehandle.read(buffer, 0, 40, 0)
await filehandle.close()
return buffer.toString().startsWith(initScriptVersionString)
} catch (err) {
logger.error(err)
return false
}
}
protected async writeInitScripts() { protected async writeInitScripts() {
const kubectlPath = userStore.preferences?.downloadKubectlBinaries ? this.dirname : path.dirname(this.getPathFromPreferences())
const helmPath = helmCli.getBinaryDir() const helmPath = helmCli.getBinaryDir()
const fsPromises = fs.promises; const fsPromises = fs.promises;
const bashScriptPath = path.join(this.dirname, '.bash_set_path') const bashScriptPath = path.join(this.dirname, '.bash_set_path')
const bashScriptIsLatest = await this.scriptIsLatest(bashScriptPath)
if (!bashScriptIsLatest) { let bashScript = "" + initScriptVersionString
let bashScript = "" + initScriptVersionString bashScript += "tempkubeconfig=\"$KUBECONFIG\"\n"
bashScript += "tempkubeconfig=\"$KUBECONFIG\"\n" bashScript += "test -f \"/etc/profile\" && . \"/etc/profile\"\n"
bashScript += "test -f \"/etc/profile\" && . \"/etc/profile\"\n" bashScript += "if test -f \"$HOME/.bash_profile\"; then\n"
bashScript += "if test -f \"$HOME/.bash_profile\"; then\n" bashScript += " . \"$HOME/.bash_profile\"\n"
bashScript += " . \"$HOME/.bash_profile\"\n" bashScript += "elif test -f \"$HOME/.bash_login\"; then\n"
bashScript += "elif test -f \"$HOME/.bash_login\"; then\n" bashScript += " . \"$HOME/.bash_login\"\n"
bashScript += " . \"$HOME/.bash_login\"\n" bashScript += "elif test -f \"$HOME/.profile\"; then\n"
bashScript += "elif test -f \"$HOME/.profile\"; then\n" bashScript += " . \"$HOME/.profile\"\n"
bashScript += " . \"$HOME/.profile\"\n" bashScript += "fi\n"
bashScript += "fi\n" bashScript += `export PATH="${helmPath}:${kubectlPath}:$PATH"\n`
bashScript += `export PATH="${this.dirname}:${helmPath}:$PATH"\n` bashScript += "export KUBECONFIG=\"$tempkubeconfig\"\n"
bashScript += "export KUBECONFIG=\"$tempkubeconfig\"\n" bashScript += "unset tempkubeconfig\n"
bashScript += "unset tempkubeconfig\n" await fsPromises.writeFile(bashScriptPath, bashScript.toString(), { mode: 0o644 })
await fsPromises.writeFile(bashScriptPath, bashScript.toString(), { mode: 0o644 })
}
const zshScriptPath = path.join(this.dirname, '.zlogin') const zshScriptPath = path.join(this.dirname, '.zlogin')
const zshScriptIsLatest = await this.scriptIsLatest(zshScriptPath)
if (!zshScriptIsLatest) {
let zshScript = "" + initScriptVersionString
zshScript += "tempkubeconfig=\"$KUBECONFIG\"\n" let zshScript = "" + initScriptVersionString
// restore previous ZDOTDIR
zshScript += "export ZDOTDIR=\"$OLD_ZDOTDIR\"\n"
// source all the files
zshScript += "test -f \"$OLD_ZDOTDIR/.zshenv\" && . \"$OLD_ZDOTDIR/.zshenv\"\n"
zshScript += "test -f \"$OLD_ZDOTDIR/.zprofile\" && . \"$OLD_ZDOTDIR/.zprofile\"\n"
zshScript += "test -f \"$OLD_ZDOTDIR/.zlogin\" && . \"$OLD_ZDOTDIR/.zlogin\"\n"
zshScript += "test -f \"$OLD_ZDOTDIR/.zshrc\" && . \"$OLD_ZDOTDIR/.zshrc\"\n"
// voodoo to replace any previous occurrences of kubectl path in the PATH zshScript += "tempkubeconfig=\"$KUBECONFIG\"\n"
zshScript += `kubectlpath=\"${this.dirname}"\n` // restore previous ZDOTDIR
zshScript += `helmpath=\"${helmPath}"\n` zshScript += "export ZDOTDIR=\"$OLD_ZDOTDIR\"\n"
zshScript += "p=\":$kubectlpath:\"\n" // source all the files
zshScript += "d=\":$PATH:\"\n" zshScript += "test -f \"$OLD_ZDOTDIR/.zshenv\" && . \"$OLD_ZDOTDIR/.zshenv\"\n"
zshScript += "d=${d//$p/:}\n" zshScript += "test -f \"$OLD_ZDOTDIR/.zprofile\" && . \"$OLD_ZDOTDIR/.zprofile\"\n"
zshScript += "d=${d/#:/}\n" zshScript += "test -f \"$OLD_ZDOTDIR/.zlogin\" && . \"$OLD_ZDOTDIR/.zlogin\"\n"
zshScript += "export PATH=\"$kubectlpath:$helmpath:${d/%:/}\"\n" zshScript += "test -f \"$OLD_ZDOTDIR/.zshrc\" && . \"$OLD_ZDOTDIR/.zshrc\"\n"
zshScript += "export KUBECONFIG=\"$tempkubeconfig\"\n"
zshScript += "unset tempkubeconfig\n" // voodoo to replace any previous occurrences of kubectl path in the PATH
zshScript += "unset OLD_ZDOTDIR\n" zshScript += `kubectlpath=\"${kubectlPath}"\n`
await fsPromises.writeFile(zshScriptPath, zshScript.toString(), { mode: 0o644 }) zshScript += `helmpath=\"${helmPath}"\n`
} zshScript += "p=\":$kubectlpath:\"\n"
zshScript += "d=\":$PATH:\"\n"
zshScript += "d=${d//$p/:}\n"
zshScript += "d=${d/#:/}\n"
zshScript += "export PATH=\"$helmpath:$kubectlpath:${d/%:/}\"\n"
zshScript += "export KUBECONFIG=\"$tempkubeconfig\"\n"
zshScript += "unset tempkubeconfig\n"
zshScript += "unset OLD_ZDOTDIR\n"
await fsPromises.writeFile(zshScriptPath, zshScript.toString(), { mode: 0o644 })
} }
protected getDownloadMirror() { protected getDownloadMirror() {

View File

@ -10,6 +10,7 @@ import { ClusterPreferences } from "../common/cluster-store";
import { helmCli } from "./helm/helm-cli" import { helmCli } from "./helm/helm-cli"
import { isWindows } from "../common/vars"; import { isWindows } from "../common/vars";
import { tracker } from "../common/tracker"; import { tracker } from "../common/tracker";
import { userStore } from "../common/user-store";
export class ShellSession extends EventEmitter { export class ShellSession extends EventEmitter {
static shellEnvs: Map<string, any> = new Map() static shellEnvs: Map<string, any> = new Map()
@ -20,6 +21,7 @@ export class ShellSession extends EventEmitter {
protected nodeShellPod: string; protected nodeShellPod: string;
protected kubectl: Kubectl; protected kubectl: Kubectl;
protected kubectlBinDir: string; protected kubectlBinDir: string;
protected kubectlPathDir: string;
protected helmBinDir: string; protected helmBinDir: string;
protected preferences: ClusterPreferences; protected preferences: ClusterPreferences;
protected running = false; protected running = false;
@ -36,6 +38,8 @@ export class ShellSession extends EventEmitter {
public async open() { public async open() {
this.kubectlBinDir = await this.kubectl.binDir() this.kubectlBinDir = await this.kubectl.binDir()
const pathFromPreferences = userStore.preferences.kubectlBinariesPath || Kubectl.bundledKubectlPath
this.kubectlPathDir = userStore.preferences.downloadKubectlBinaries ? await this.kubectl.binDir() : path.dirname(pathFromPreferences)
this.helmBinDir = helmCli.getBinaryDir() this.helmBinDir = helmCli.getBinaryDir()
const env = await this.getCachedShellEnv() const env = await this.getCachedShellEnv()
const shell = env.PTYSHELL const shell = env.PTYSHELL
@ -67,11 +71,11 @@ export class ShellSession extends EventEmitter {
protected async getShellArgs(shell: string): Promise<Array<string>> { protected async getShellArgs(shell: string): Promise<Array<string>> {
switch(path.basename(shell)) { switch(path.basename(shell)) {
case "powershell.exe": case "powershell.exe":
return ["-NoExit", "-command", `& {Set-Location $Env:USERPROFILE; $Env:PATH="${this.kubectlBinDir};${this.helmBinDir};$Env:PATH"}`] return ["-NoExit", "-command", `& {Set-Location $Env:USERPROFILE; $Env:PATH="${this.helmBinDir};${this.kubectlPathDir};$Env:PATH"}`]
case "bash": case "bash":
return ["--init-file", path.join(this.kubectlBinDir, '.bash_set_path')] return ["--init-file", path.join(this.kubectlBinDir, '.bash_set_path')]
case "fish": case "fish":
return ["--login", "--init-command", `export PATH="${this.kubectlBinDir}:${this.helmBinDir}:$PATH"; export KUBECONFIG="${this.kubeconfigPath}"`] return ["--login", "--init-command", `export PATH="${this.helmBinDir}:${this.kubectlPathDir}:$PATH"; export KUBECONFIG="${this.kubeconfigPath}"`]
case "zsh": case "zsh":
return ["--login"] return ["--login"]
default: default:

View File

@ -0,0 +1,83 @@
import React, { useState } from 'react';
import { Trans } from '@lingui/macro';
import { isPath } from '../input/input.validators';
import { Checkbox } from '../checkbox';
import { Input } from '../input';
import { SubTitle } from '../layout/sub-title';
import { UserPreferences, userStore } from '../../../common/user-store';
import { observer } from 'mobx-react';
import { Kubectl } from '../../../main/kubectl';
import { SelectOption, Select } from '../select';
export const KubectlBinaries = observer(({ preferences }: { preferences: UserPreferences }) => {
const [downloadPath, setDownloadPath] = useState(preferences.downloadBinariesPath || "");
const [binariesPath, setBinariesPath] = useState(preferences.kubectlBinariesPath || "");
const downloadMirrorOptions: SelectOption<string>[] = [
{ value: "default", label: "Default (Google)" },
{ value: "china", label: "China (Azure)" },
]
const save = () => {
preferences.downloadBinariesPath = downloadPath;
preferences.kubectlBinariesPath = binariesPath;
}
const renderPath = () => {
if (preferences.downloadKubectlBinaries) {
return null;
}
return (
<>
<SubTitle title="Path to Kubectl binary"/>
<Input
theme="round-black"
value={binariesPath}
validators={isPath}
onChange={setBinariesPath}
onBlur={save}
/>
<small className="hint">
<Trans>Default:</Trans>{" "}{Kubectl.bundledKubectlPath}
</small>
</>
);
}
return (
<>
<h2><Trans>Kubectl Binary</Trans></h2>
<small className="hint">
<Trans>Download kubectl binaries matching to Kubernetes cluster verison.</Trans>
</small>
<Checkbox
label={<Trans>Download kubectl binaries</Trans>}
value={preferences.downloadKubectlBinaries}
onChange={downloadKubectlBinaries => preferences.downloadKubectlBinaries = downloadKubectlBinaries}
/>
<SubTitle title="Download mirror" />
<Select
placeholder={<Trans>Download mirror for kubectl</Trans>}
options={downloadMirrorOptions}
value={preferences.downloadMirror}
onChange={({ value }: SelectOption) => preferences.downloadMirror = value}
disabled={!preferences.downloadKubectlBinaries}
/>
<SubTitle title="Directory for binaries"/>
<Input
theme="round-black"
value={downloadPath}
placeholder={`Directory to download binaries into`}
validators={isPath}
onChange={setDownloadPath}
onBlur={save}
disabled={!preferences.downloadKubectlBinaries}
/>
<small>
Default: {userStore.getDefaultKubectlPath()}
</small>
{renderPath()}
</>
);
});

View File

@ -19,6 +19,11 @@
} }
} }
.SubTitle {
text-transform: none;
margin: 0!important;
}
.repos { .repos {
position: relative; position: relative;

View File

@ -16,18 +16,13 @@ import { Badge } from "../badge";
import { themeStore } from "../../theme.store"; import { themeStore } from "../../theme.store";
import { history } from "../../navigation"; import { history } from "../../navigation";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
import { KubectlBinaries } from "./kubectl-binaries";
@observer @observer
export class Preferences extends React.Component { export class Preferences extends React.Component {
@observable helmLoading = false; @observable helmLoading = false;
@observable helmRepos: HelmRepo[] = []; @observable helmRepos: HelmRepo[] = [];
@observable helmAddedRepos = observable.map<string, HelmRepo>(); @observable helmAddedRepos = observable.map<string, HelmRepo>();
@observable downloadMirrorOptions: SelectOption<string>[] = [
{ value: "default", label: "Default (Google)" },
{ value: "china", label: "China (Azure)" },
]
@observable httpProxy = userStore.preferences.httpsProxy || ""; @observable httpProxy = userStore.preferences.httpsProxy || "";
@computed get themeOptions(): SelectOption<string>[] { @computed get themeOptions(): SelectOption<string>[] {
@ -135,13 +130,19 @@ export class Preferences extends React.Component {
onChange={({ value }: SelectOption) => preferences.colorTheme = value} onChange={({ value }: SelectOption) => preferences.colorTheme = value}
/> />
<h2><Trans>Download Mirror</Trans></h2> <h2><Trans>HTTP Proxy</Trans></h2>
<Select <Input
placeholder={<Trans>Download mirror for kubectl</Trans>} theme="round-black"
options={this.downloadMirrorOptions} placeholder={_i18n._(t`Type HTTP proxy url (example: http://proxy.acme.org:8080)`)}
value={preferences.downloadMirror} value={this.httpProxy}
onChange={({ value }: SelectOption) => preferences.downloadMirror = value} onChange={v => this.httpProxy = v}
onBlur={() => preferences.httpsProxy = this.httpProxy}
/> />
<small className="hint">
<Trans>Proxy is used only for non-cluster communication.</Trans>
</small>
<KubectlBinaries preferences={preferences} />
<h2><Trans>Helm</Trans></h2> <h2><Trans>Helm</Trans></h2>
<Select <Select
@ -172,18 +173,6 @@ export class Preferences extends React.Component {
})} })}
</div> </div>
<h2><Trans>HTTP Proxy</Trans></h2>
<Input
theme="round-black"
placeholder={_i18n._(t`Type HTTP proxy url (example: http://proxy.acme.org:8080)`)}
value={this.httpProxy}
onChange={v => this.httpProxy = v}
onBlur={() => preferences.httpsProxy = this.httpProxy}
/>
<small className="hint">
<Trans>Proxy is used only for non-cluster communication.</Trans>
</small>
<h2><Trans>Certificate Trust</Trans></h2> <h2><Trans>Certificate Trust</Trans></h2>
<Checkbox <Checkbox
label={<Trans>Allow untrusted Certificate Authorities</Trans>} label={<Trans>Allow untrusted Certificate Authorities</Trans>}

View File

@ -2,6 +2,7 @@ import type { InputProps } from "./input";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { t } from "@lingui/macro"; import { t } from "@lingui/macro";
import { _i18n } from '../../i18n'; import { _i18n } from '../../i18n';
import fse from "fs-extra";
export interface Validator { export interface Validator {
debounce?: number; // debounce for async validators in ms debounce?: number; // debounce for async validators in ms
@ -41,6 +42,12 @@ export const isUrl: Validator = {
validate: value => !!value.match(/^http(s)?:\/\/\w+(\.\w+)*(:[0-9]+)?\/?(\/[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]*)*$/), validate: value => !!value.match(/^http(s)?:\/\/\w+(\.\w+)*(:[0-9]+)?\/?(\/[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]*)*$/),
}; };
export const isPath: Validator = {
condition: ({ type }) => type === "text",
message: () => _i18n._(t`This field must be a path to an existing file`),
validate: value => !value || fse.pathExistsSync(value),
}
export const minLength: Validator = { export const minLength: Validator = {
condition: ({ minLength }) => !!minLength, condition: ({ minLength }) => !!minLength,
message: (value, { minLength }) => _i18n._(t`Minimum length is ${minLength}`), message: (value, { minLength }) => _i18n._(t`Minimum length is ${minLength}`),