Allow user to select Kubeconfig from filesystem, fix #738

Signed-off-by: Roman <ixrock@gmail.com>
This commit is contained in:
Roman committed 2020-08-24 19:24:24 +03:00
1 parent 0f11b03692
commit f067152d04
9 files changed
+252 -91

No files matched your search

+3 -16
View File
@@ -1,11 +1,13 @@
import { app, remote } from "electron";
import { KubeConfig, V1Node, V1Pod } from "@kubernetes/client-node"
import fse, { ensureDirSync, readFile, writeFileSync } from "fs-extra";
import fse, { ensureDirSync, writeFileSync } from "fs-extra";
import path from "path"
import os from "os"
import yaml from "js-yaml"
import logger from "../main/logger";
export const kubeConfigDefaultPath = path.join(os.homedir(), '.kube', 'config');
function resolveTilde(filePath: string) {
if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) {
return filePath.replace("~", os.homedir());
@@ -150,18 +152,3 @@ export function saveConfigToAppFiles(clusterId: string, kubeConfig: KubeConfig |
writeFileSync(kubeConfigFile, kubeConfigContents);
return kubeConfigFile;
}
export async function getKubeConfigLocal(): Promise<string> {
try {
const configFile = path.join(os.homedir(), '.kube', 'config');
const file = await readFile(configFile, "utf8");
const obj = yaml.safeLoad(file);
if (obj.contexts) {
obj.contexts = obj.contexts.filter((ctx: any) => ctx?.context?.cluster && ctx?.name)
}
return yaml.safeDump(obj);
} catch (err) {
logger.debug(`Cannot read local kube-config: ${err}`)
return "";
}
}
+29 -12
View File
@@ -1,13 +1,16 @@
import type { ThemeId } from "../renderer/theme.store";
import semver from "semver"
import { readFile } from "fs-extra"
import { action, observable, reaction, toJS } from "mobx";
import { BaseStore } from "./base-store";
import migrations from "../migrations/user-store"
import { getAppVersion } from "./utils/app-version";
import { getKubeConfigLocal, loadConfig } from "./kube-helpers";
import { kubeConfigDefaultPath, loadConfig } from "./kube-helpers";
import { tracker } from "./tracker";
import logger from "../main/logger";
export interface UserStoreModel {
kubeConfigPath: string;
lastSeenAppVersion: string;
seenContexts: string[];
preferences: UserPreferences;
@@ -37,10 +40,11 @@ export class UserStore extends BaseStore<UserStoreModel> {
// refresh new contexts
this.whenLoaded.then(this.refreshNewContexts);
reaction(() => this.seenContexts.size, this.refreshNewContexts);
reaction(() => this.kubeConfigPath, this.refreshNewContexts);
}
@observable lastSeenAppVersion = "0.0.0"
@observable kubeConfigPath = kubeConfigDefaultPath; // used in add-cluster page for providing context
@observable seenContexts = observable.set<string>();
@observable newContexts = observable.set<string>();
@@ -55,6 +59,11 @@ export class UserStore extends BaseStore<UserStoreModel> {
return semver.gt(getAppVersion(), this.lastSeenAppVersion);
}
@action
resetKubeConfigPath() {
this.kubeConfigPath = kubeConfigDefaultPath;
}
@action
resetTheme() {
this.preferences.colorTheme = UserStore.defaultTheme;
@@ -67,14 +76,18 @@ export class UserStore extends BaseStore<UserStoreModel> {
}
protected refreshNewContexts = async () => {
const kubeConfig = await getKubeConfigLocal();
if (kubeConfig) {
this.newContexts.clear();
const localContexts = loadConfig(kubeConfig).getContexts();
localContexts
.filter(ctx => ctx.cluster)
.filter(ctx => !this.seenContexts.has(ctx.name))
.forEach(ctx => this.newContexts.add(ctx.name));
try {
const kubeConfig = await readFile(this.kubeConfigPath, "utf8");
if (kubeConfig) {
this.newContexts.clear();
loadConfig(kubeConfig).getContexts()
.filter(ctx => ctx.cluster)
.filter(ctx => !this.seenContexts.has(ctx.name))
.forEach(ctx => this.newContexts.add(ctx.name));
}
} catch (err) {
logger.error(err);
this.resetKubeConfigPath();
}
}
@@ -86,17 +99,21 @@ export class UserStore extends BaseStore<UserStoreModel> {
}
@action
protected fromStore(data: Partial<UserStoreModel> = {}) {
const { lastSeenAppVersion, seenContexts = [], preferences } = data
protected async fromStore(data: Partial<UserStoreModel> = {}) {
const { lastSeenAppVersion, seenContexts = [], preferences, kubeConfigPath } = data
if (lastSeenAppVersion) {
this.lastSeenAppVersion = lastSeenAppVersion;
}
if (kubeConfigPath) {
this.kubeConfigPath = kubeConfigPath;
}
this.seenContexts.replace(seenContexts);
Object.assign(this.preferences, preferences);
}
toJSON(): UserStoreModel {
const model: UserStoreModel = {
kubeConfigPath: this.kubeConfigPath,
lastSeenAppVersion: this.lastSeenAppVersion,
seenContexts: Array.from(this.seenContexts),
preferences: this.preferences,
@@ -1,10 +1,34 @@
.AddCluster {
.Select {
.kube-context {
--flex-gap: #{$padding};
}
&__control {
box-shadow: 0 0 0 1px $borderFaintColor;
}
}
.kube-config-select {
border: 1px solid $borderColor;
padding: $padding / 2 $padding;
border-radius: $radius;
transition: border 200ms ease-in-out;
> .title {
white-space: nowrap;
}
&:hover {
border-color: $primary;
cursor: pointer;
}
code {
word-break: break-all;
}
}
code {
color: $pink-400;
}
@@ -3,27 +3,28 @@ import React, { Fragment } from "react";
import { observer } from "mobx-react";
import { computed, observable } from "mobx";
import { KubeConfig } from "@kubernetes/client-node";
import { t, Trans } from "@lingui/macro";
import { _i18n } from "../../i18n";
import { Trans } from "@lingui/macro";
import { Select, SelectOption } from "../select";
import { Input } from "../input";
import { FileInput, Input } from "../input";
import { AceEditor } from "../ace-editor";
import { Button } from "../button";
import { Icon } from "../icon";
import { WizardLayout } from "../layout/wizard-layout";
import { getKubeConfigLocal, loadConfig, saveConfigToAppFiles, splitConfig, validateConfig } from "../../../common/kube-helpers";
import { kubeConfigDefaultPath, loadConfig, saveConfigToAppFiles, splitConfig, validateConfig } from "../../../common/kube-helpers";
import { clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store";
import { v4 as uuid } from "uuid"
import { navigate } from "../../navigation";
import { userStore } from "../../../common/user-store";
import { clusterViewURL } from "../cluster-manager/cluster-view.route";
import { cssNames } from "../../utils";
import { Notifications } from "../notifications";
@observer
export class AddCluster extends React.Component {
readonly custom: any = "custom"
@observable.ref clusterConfig: KubeConfig;
@observable.ref kubeConfig: KubeConfig; // local ~/.kube/config (if available)
@observable.ref localKubeConfig: KubeConfig;
@observable.ref newClusterConfig: KubeConfig;
@observable.ref error: React.ReactNode;
@observable isWaiting = false
@@ -31,25 +32,36 @@ export class AddCluster extends React.Component {
@observable proxyServer = ""
@observable customConfig = ""
async componentDidMount() {
const kubeConfig: string = await getKubeConfigLocal()
if (kubeConfig) {
this.kubeConfig = loadConfig(kubeConfig)
}
componentDidMount() {
this.setLocalConfigPath(userStore.kubeConfigPath);
}
componentWillUnmount() {
userStore.markNewContextsAsSeen();
}
protected setLocalConfigPath(filePath: string) {
try {
const kubeConfig = loadConfig(filePath);
validateConfig(kubeConfig);
this.localKubeConfig = kubeConfig;
this.newClusterConfig = null; // reset previously selected
userStore.kubeConfigPath = filePath; // save to store
} catch (err) {
Notifications.error(
<p>Can't read config file in <em>{filePath}</em>: {String(err)}</p>
);
}
}
@computed get isCustom() {
return this.clusterConfig === this.custom;
return this.newClusterConfig === this.custom;
}
@computed get clusterOptions() {
const options: SelectOption<KubeConfig>[] = [];
if (this.kubeConfig) {
splitConfig(this.kubeConfig).forEach(kubeConfig => {
if (this.localKubeConfig) {
splitConfig(this.localKubeConfig).forEach(kubeConfig => {
const context = kubeConfig.currentContext;
const hasContext = clusterStore.hasContext(context);
if (!hasContext) {
@@ -71,9 +83,8 @@ export class AddCluster extends React.Component {
if (value instanceof KubeConfig) {
const context = value.currentContext;
const isNew = userStore.newContexts.has(context);
const className = `${context} kube-context flex gaps align-center`
return (
<div className={className}>
<div className={cssNames("kube-context flex gaps align-center", context)}>
<span>{context}</span>
{isNew && <Icon material="fiber_new"/>}
</div>
@@ -83,14 +94,14 @@ export class AddCluster extends React.Component {
};
addCluster = async () => {
const { clusterConfig, customConfig, proxyServer } = this;
const { newClusterConfig, customConfig, proxyServer } = this;
const clusterId = uuid();
this.isWaiting = true
this.error = ""
try {
const config = this.isCustom ? loadConfig(customConfig) : clusterConfig;
const config = this.isCustom ? loadConfig(customConfig) : newClusterConfig;
if (!config) {
this.error = <Trans>Please select kubeconfig</Trans>
this.error = <Trans>Please select kube-config's context</Trans>
return;
}
validateConfig(config);
@@ -167,12 +178,31 @@ export class AddCluster extends React.Component {
return (
<WizardLayout className="AddCluster" infoPanel={this.renderInfo()}>
<h2><Trans>Add Cluster</Trans></h2>
<p>Choose config:</p>
<div className="flex gaps align-center">
<label
htmlFor="kube-config-select"
className="kube-config-select flex gaps align-center box grow"
>
<span className="title">Config file</span>
<code>{userStore.kubeConfigPath}</code>
</label>
{kubeConfigDefaultPath !== userStore.kubeConfigPath && (
<Icon
material="settings_backup_restore"
onClick={() => this.setLocalConfigPath(kubeConfigDefaultPath)}
tooltip="Reset to defaults"
/>
)}
</div>
<FileInput
id="kube-config-select"
onSelectFiles={({ file }) => this.setLocalConfigPath(file.path)}
/>
<Select
placeholder={<Trans>Select kubeconfig</Trans>}
value={this.clusterConfig}
placeholder={<Trans>Select a context</Trans>}
value={this.newClusterConfig}
options={this.clusterOptions}
onChange={({ value }: SelectOption) => this.clusterConfig = value}
onChange={({ value }: SelectOption) => this.newClusterConfig = value}
formatOptionLabel={this.formatClusterContextLabel}
id="kubecontext-select"
/>
@@ -0,0 +1,66 @@
import React, { InputHTMLAttributes } from "react";
export interface FileInputSelection<T = string> {
file: File;
data?: T | any; // not available when readAsTexts={false}
error?: string;
}
interface Props extends InputHTMLAttributes<any> {
id?: string; // could be used with <label htmlFor={id}/> to open filesystem dialog
accept?: string; // allowed file types to select, e.g. "application/json"
readAsText?: boolean; // provide files content as text in selection-callback
multiple?: boolean;
onSelectFiles(...selectedFiles: FileInputSelection[]): void;
}
export class FileInput extends React.Component<Props> {
protected input: HTMLInputElement;
protected style: React.CSSProperties = {
position: "absolute",
display: "none",
};
selectFiles = () => {
this.input.click(); // opens system dialog for selecting files
}
protected onChange = async (evt: React.ChangeEvent<HTMLInputElement>) => {
const fileList = Array.from(evt.target.files);
if (!fileList.length) {
return;
}
let selectedFiles: FileInputSelection[] = fileList.map(file => ({ file }));
if (this.props.readAsText) {
const readingFiles: Promise<FileInputSelection>[] = fileList.map(file => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
resolve({
file: file,
data: reader.result,
error: reader.error ? String(reader.error) : null,
})
};
reader.readAsText(file);
})
});
selectedFiles = await Promise.all(readingFiles);
}
this.props.onSelectFiles(...selectedFiles);
}
render() {
const { onSelectFiles, readAsText, ...props } = this.props;
return (
<input
type="file"
style={this.style}
onChange={this.onChange}
ref={e => this.input = e}
{...props}
/>
)
}
}
+1
View File
@@ -1,2 +1,3 @@
export * from './input'
export * from './search-input'
export * from './file-input'