diff --git a/.azure-pipelines.yml b/.azure-pipelines.yml index 05d7117e19..2a4751ff33 100644 --- a/.azure-pipelines.yml +++ b/.azure-pipelines.yml @@ -44,7 +44,6 @@ jobs: WIN_CSC_KEY_PASSWORD: $(WIN_CSC_KEY_PASSWORD) GH_TOKEN: $(GH_TOKEN) - job: macOS - condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" pool: vmImage: macOS-10.14 strategy: @@ -75,7 +74,10 @@ jobs: displayName: Lint - script: make test displayName: Run tests + - script: make integration-mac + displayName: Run integration tests - script: make build + condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" displayName: Build env: APPLEID: $(APPLEID) @@ -119,6 +121,15 @@ jobs: displayName: Lint - script: make test displayName: Run tests + - bash: | + sudo apt-get update + sudo apt-get install libgconf-2-4 conntrack -y + curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64 + sudo install minikube-linux-amd64 /usr/local/bin/minikube + sudo minikube start --driver=none + displayName: Install integration test dependencies + - script: xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' make integration-linux + displayName: Run integration tests - bash: | sudo chown root:root / sudo apt-get update && sudo apt-get install -y snapd diff --git a/Makefile b/Makefile index 1e1d58364b..6139afdfba 100644 --- a/Makefile +++ b/Makefile @@ -6,11 +6,26 @@ endif .PHONY: dev build test clean +download-bins: + yarn download:bins + dev: app-deps dashboard-deps yarn dev test: test-app test-dashboard +integration-linux: + yarn build:linux + yarn integration + +integration-mac: + yarn build:mac + yarn integration + +integration-win: + yarn build:win + yarn integration + lint: yarn lint diff --git a/README.md b/README.md index 014c5275c0..cbf66a602d 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Download a pre-built package from the [releases](https://github.com/lensapp/lens > Prerequisities: Nodejs v12, make, yarn +* `make download-bins` - downloads bundled binaries to dev environment * `make dev` - builds and starts the app * `make test` - run tests diff --git a/dashboard/client/api/endpoints/helm-releases.api.ts b/dashboard/client/api/endpoints/helm-releases.api.ts index dfd284905f..5e676f3472 100644 --- a/dashboard/client/api/endpoints/helm-releases.api.ts +++ b/dashboard/client/api/endpoints/helm-releases.api.ts @@ -198,21 +198,4 @@ export class HelmRelease implements ItemObject { const chartVersion = versions.find(chartVersion => chartVersion.version === version); return chartVersion ? chartVersion.repo : ""; } - - getLastVersion(): string | null { - const chartName = this.getChart(); - const versions = helmChartStore.versions.get(chartName); - if (!versions) { - return null; // checking new version state - } - if (versions.length) { - return versions[0].version; // versions already sorted when loaded, the first is latest - } - return this.getVersion(); - } - - hasNewVersion() { - const lastVersion = this.getLastVersion(); - return lastVersion && lastVersion !== this.getVersion(); - } } diff --git a/dashboard/client/api/json-api.ts b/dashboard/client/api/json-api.ts index abf278497a..5f50296b0d 100644 --- a/dashboard/client/api/json-api.ts +++ b/dashboard/client/api/json-api.ts @@ -105,8 +105,9 @@ export class JsonApi { this.onData.emit(data, res); this.writeLog({ ...log, data }); return data; - } - else { + } else if (log.method === "GET" && res.status === 403) { + this.writeLog({ ...log, data }); + } else { const error = new JsonApiErrorParsed(data, this.parseError(data, res)); this.onError.emit(error, res); this.writeLog({ ...log, error }) diff --git a/dashboard/client/api/kube-watch-api.ts b/dashboard/client/api/kube-watch-api.ts index b6abe9fb18..3628f9d790 100644 --- a/dashboard/client/api/kube-watch-api.ts +++ b/dashboard/client/api/kube-watch-api.ts @@ -107,8 +107,12 @@ export class KubeWatchApi { const { apiBase, namespace } = KubeApi.parseApi(url); const api = apiManager.getApi(apiBase); if (api) { - await api.refreshResourceVersion({ namespace }); - this.reconnect(); + try { + await api.refreshResourceVersion({ namespace }); + this.reconnect(); + } catch(error) { + console.debug("failed to refresh resource version", error) + } } } } diff --git a/dashboard/client/api/rbac.ts b/dashboard/client/api/rbac.ts new file mode 100644 index 0000000000..b15b83353e --- /dev/null +++ b/dashboard/client/api/rbac.ts @@ -0,0 +1,15 @@ +import { configStore } from "../config.store"; +import { isArray } from "util"; + +export function isAllowedResource(resources: string|string[]) { + if (!isArray(resources)) { + resources = [resources]; + } + const { allowedResources } = configStore; + for (const resource of resources) { + if (!allowedResources.includes(resource)) { + return false; + } + } + return true; +} diff --git a/dashboard/client/components/+apps-releases/release-details.tsx b/dashboard/client/components/+apps-releases/release-details.tsx index 9cb3fef060..cb7c7b8069 100644 --- a/dashboard/client/components/+apps-releases/release-details.tsx +++ b/dashboard/client/components/+apps-releases/release-details.tsx @@ -19,7 +19,6 @@ import { AceEditor } from "../ace-editor"; import { Button } from "../button"; import { releaseStore } from "./release.store"; import { Notifications } from "../notifications"; -import { Icon } from "../icon"; import { createUpgradeChartTab } from "../dock/upgrade-chart.store"; import { getDetailsUrl } from "../../navigation"; import { _i18n } from "../../i18n"; @@ -190,14 +189,12 @@ export class ReleaseDetails extends Component { Chart} className="chart">
{release.getChart()} - {release.hasNewVersion() && ( -
Updated}> @@ -211,12 +208,6 @@ export class ReleaseDetails extends Component { {release.getVersion()} - {!release.getLastVersion() && ( - - )} - {release.hasNewVersion() && ( - New version available: {release.getLastVersion()} - )} Status} className="status" labelsOnly> diff --git a/dashboard/client/components/+apps-releases/release-menu.tsx b/dashboard/client/components/+apps-releases/release-menu.tsx index 9884f72778..aaea69ade9 100644 --- a/dashboard/client/components/+apps-releases/release-menu.tsx +++ b/dashboard/client/components/+apps-releases/release-menu.tsx @@ -37,7 +37,6 @@ export class HelmReleaseMenu extends React.Component { const { release, toolbar } = this.props; if (!release) return; const hasRollback = release && release.getRevision() > 1; - const hasNewVersion = release.hasNewVersion(); return ( <> {hasRollback && ( @@ -46,12 +45,6 @@ export class HelmReleaseMenu extends React.Component { Rollback )} - {hasNewVersion && ( - - - Upgrade - - )} ) } diff --git a/dashboard/client/components/+apps-releases/releases.tsx b/dashboard/client/components/+apps-releases/releases.tsx index 558aee7d36..234a812543 100644 --- a/dashboard/client/components/+apps-releases/releases.tsx +++ b/dashboard/client/components/+apps-releases/releases.tsx @@ -5,9 +5,7 @@ import kebabCase from "lodash/kebabCase"; import { observer } from "mobx-react"; import { Trans } from "@lingui/macro"; import { RouteComponentProps } from "react-router"; -import { autobind, interval } from "../../utils"; import { releaseStore } from "./release.store"; -import { helmChartStore } from "../+apps-helm-charts/helm-chart.store"; import { IReleaseRouteParams, releaseURL } from "./release.route"; import { HelmRelease } from "../../api/endpoints/helm-releases.api"; import { ReleaseDetails } from "./release-details"; @@ -15,9 +13,7 @@ import { ReleaseRollbackDialog } from "./release-rollback-dialog"; import { navigation } from "../../navigation"; import { ItemListLayout } from "../item-object-list/item-list-layout"; import { HelmReleaseMenu } from "./release-menu"; -import { Icon } from "../icon"; import { secretsStore } from "../+config-secrets/secrets.store"; -import { when } from "mobx"; enum sortBy { name = "name", @@ -33,31 +29,16 @@ interface Props extends RouteComponentProps { @observer export class HelmReleases extends Component { - private versionsWatcher = interval(3600, this.checkVersions); componentDidMount() { // Watch for secrets associated with releases and react to their changes releaseStore.watch(); - this.versionsWatcher.start(); - when(() => releaseStore.isLoaded, this.checkVersions); } componentWillUnmount() { releaseStore.unwatch(); - this.versionsWatcher.stop(); } - // Check all available versions every 1 hour for installed releases. - // This required to show "upgrade" icon in the list and upgrade button in the details view. - @autobind() - checkVersions() { - const charts = releaseStore.items.map(release => release.getChart()); - return charts.reduce((promise, chartName) => { - const loadVersions = () => helmChartStore.getVersions(chartName, true); - return promise.then(loadVersions, loadVersions); - }, Promise.resolve({})) - }; - get selectedRelease() { const { match: { params: { name, namespace } } } = this.props; return releaseStore.items.find(release => { @@ -130,7 +111,6 @@ export class HelmReleases extends Component { ]} renderTableContents={(release: HelmRelease) => { const version = release.getVersion(); - const lastVersion = release.getLastVersion(); return [ release.getName(), release.getNs(), @@ -138,20 +118,6 @@ export class HelmReleases extends Component { release.getRevision(), <> {version} - {!lastVersion && ( - Checking update} - /> - )} - {release.hasNewVersion() && ( - New version: {lastVersion}} - /> - )} , release.appVersion, { title: release.getStatus(), className: kebabCase(release.getStatus()) }, diff --git a/dashboard/client/components/+cluster/cluster.tsx b/dashboard/client/components/+cluster/cluster.tsx index 979f811067..13df2cad0a 100644 --- a/dashboard/client/components/+cluster/cluster.tsx +++ b/dashboard/client/components/+cluster/cluster.tsx @@ -13,6 +13,7 @@ import { nodesStore } from "../+nodes/nodes.store"; import { podsStore } from "../+workloads-pods/pods.store"; import { clusterStore } from "./cluster.store"; import { eventStore } from "../+events/event.store"; +import { isAllowedResource } from "../../api/rbac"; @observer export class Cluster extends React.Component { @@ -25,6 +26,9 @@ export class Cluster extends React.Component { async componentDidMount() { const { dependentStores } = this; + if (!isAllowedResource("nodes")) { + dependentStores.splice(dependentStores.indexOf(nodesStore), 1) + } this.watchers.forEach(watcher => watcher.start(true)); await Promise.all([ diff --git a/dashboard/client/components/+config/config.tsx b/dashboard/client/components/+config/config.tsx index 935cd9affd..71c6ead907 100644 --- a/dashboard/client/components/+config/config.tsx +++ b/dashboard/client/components/+config/config.tsx @@ -9,8 +9,8 @@ import { namespaceStore } from "../+namespaces/namespace.store"; import { resourceQuotaRoute, ResourceQuotas, resourceQuotaURL } from "../+config-resource-quotas"; import { configURL } from "./config.route"; import { HorizontalPodAutoscalers, hpaRoute, hpaURL } from "../+config-autoscalers"; -import { Certificates, ClusterIssuers, Issuers } from "../+custom-resources/certmanager.k8s.io"; import { buildURL } from "../../navigation"; +import { isAllowedResource } from "../../api/rbac" export const certificatesURL = buildURL("/certificates"); export const issuersURL = buildURL("/issuers"); @@ -20,32 +20,40 @@ export const clusterIssuersURL = buildURL("/clusterissuers"); export class Config extends React.Component { static get tabRoutes(): TabRoute[] { const query = namespaceStore.getContextParams() - return [ - { + const routes: TabRoute[] = [] + if (isAllowedResource("configmaps")) { + routes.push({ title: ConfigMaps, component: ConfigMaps, url: configMapsURL({ query }), path: configMapsRoute.path, - }, - { + }) + } + if (isAllowedResource("secrets")) { + routes.push({ title: Secrets, component: Secrets, url: secretsURL({ query }), path: secretsRoute.path, - }, - { + }) + } + if (isAllowedResource("resourcequotas")) { + routes.push({ title: Resource Quotas, component: ResourceQuotas, url: resourceQuotaURL({ query }), path: resourceQuotaRoute.path, - }, - { + }) + } + if (isAllowedResource("horizontalpodautoscalers")) { + routes.push({ title: HPA, component: HorizontalPodAutoscalers, url: hpaURL({ query }), path: hpaRoute.path, - }, - ] + }) + } + return routes; } render() { diff --git a/dashboard/client/components/+namespaces/namespace-select.tsx b/dashboard/client/components/+namespaces/namespace-select.tsx index b5004685c9..b4b5fd15cb 100644 --- a/dashboard/client/components/+namespaces/namespace-select.tsx +++ b/dashboard/client/components/+namespaces/namespace-select.tsx @@ -11,6 +11,7 @@ import { namespaceStore } from "./namespace.store"; import { _i18n } from "../../i18n"; import { FilterIcon } from "../item-object-list/filter-icon"; import { FilterType } from "../item-object-list/page-filters.store"; +import { isAllowedResource } from "../../api/rbac" interface Props extends SelectProps { showIcons?: boolean; @@ -33,7 +34,9 @@ export class NamespaceSelect extends React.Component { private unsubscribe = noop; async componentDidMount() { - if (!namespaceStore.isLoaded) await namespaceStore.loadAll(); + if (isAllowedResource("namespaces") && !namespaceStore.isLoaded) { + await namespaceStore.loadAll(); + } this.unsubscribe = namespaceStore.subscribe(); } diff --git a/dashboard/client/components/+network/network.tsx b/dashboard/client/components/+network/network.tsx index 409bde8fc2..768d732547 100644 --- a/dashboard/client/components/+network/network.tsx +++ b/dashboard/client/components/+network/network.tsx @@ -12,6 +12,7 @@ import { Ingresses, ingressRoute, ingressURL } from "../+network-ingresses"; import { NetworkPolicies, networkPoliciesRoute, networkPoliciesURL } from "../+network-policies"; import { namespaceStore } from "../+namespaces/namespace.store"; import { networkURL } from "./network.route"; +import { isAllowedResource } from "../../api/rbac"; interface Props extends RouteComponentProps<{}> { } @@ -20,32 +21,40 @@ interface Props extends RouteComponentProps<{}> { export class Network extends React.Component { static get tabRoutes(): TabRoute[] { const query = namespaceStore.getContextParams() - return [ - { + const routes: TabRoute[] = []; + if (isAllowedResource("services")) { + routes.push({ title: Services, component: Services, url: servicesURL({ query }), path: servicesRoute.path, - }, - { + }) + } + if (isAllowedResource("endpoints")) { + routes.push({ title: Endpoints, component: Endpoints, url: endpointURL({ query }), path: endpointRoute.path, - }, - { + }) + } + if (isAllowedResource("ingresses")) { + routes.push({ title: Ingresses, component: Ingresses, url: ingressURL({ query }), path: ingressRoute.path, - }, - { + }) + } + if (isAllowedResource("networkpolicies")) { + routes.push({ title: Network Policies, component: NetworkPolicies, url: networkPoliciesURL({ query }), path: networkPoliciesRoute.path, - }, - ] + }) + } + return routes } render() { @@ -59,4 +68,4 @@ export class Network extends React.Component { ) } -} \ No newline at end of file +} diff --git a/dashboard/client/components/+workloads-overview/overview-statuses.scss b/dashboard/client/components/+workloads-overview/overview-statuses.scss index 7160d67821..21e6e17cfb 100644 --- a/dashboard/client/components/+workloads-overview/overview-statuses.scss +++ b/dashboard/client/components/+workloads-overview/overview-statuses.scss @@ -16,10 +16,11 @@ .workloads { display: grid; grid-template-columns: repeat(auto-fit, 155px); - justify-content: space-between; + justify-content: space-evenly; grid-gap: $margin; padding: $padding * 2; + .workload { margin-bottom: $margin * 2; @@ -32,4 +33,4 @@ } } } -} \ No newline at end of file +} diff --git a/dashboard/client/components/+workloads-overview/overview-statuses.tsx b/dashboard/client/components/+workloads-overview/overview-statuses.tsx index 3c0a8cac0d..83575fec86 100644 --- a/dashboard/client/components/+workloads-overview/overview-statuses.tsx +++ b/dashboard/client/components/+workloads-overview/overview-statuses.tsx @@ -15,17 +15,20 @@ import { cronJobStore } from "../+workloads-cronjobs/cronjob.store"; import { namespaceStore } from "../+namespaces/namespace.store"; import { PageFiltersList } from "../item-object-list/page-filters-list"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select"; +import { configStore } from "../../config.store"; +import { isAllowedResource } from "../../api/rbac"; @observer export class OverviewStatuses extends React.Component { render() { + const { allowedResources } = configStore; const { contextNs } = namespaceStore; - const pods = podsStore.getAllByNs(contextNs); - const deployments = deploymentStore.getAllByNs(contextNs); - const statefulSets = statefulSetStore.getAllByNs(contextNs); - const daemonSets = daemonSetStore.getAllByNs(contextNs); - const jobs = jobStore.getAllByNs(contextNs); - const cronJobs = cronJobStore.getAllByNs(contextNs); + const pods = isAllowedResource("pods") ? podsStore.getAllByNs(contextNs) : []; + const deployments = isAllowedResource("deployments") ? deploymentStore.getAllByNs(contextNs) : []; + const statefulSets = isAllowedResource("statefulsets") ? statefulSetStore.getAllByNs(contextNs) : []; + const daemonSets = isAllowedResource("daemonsets") ? daemonSetStore.getAllByNs(contextNs) : []; + const jobs = isAllowedResource("jobs") ? jobStore.getAllByNs(contextNs) : []; + const cronJobs = isAllowedResource("cronjobs") ? cronJobStore.getAllByNs(contextNs) : []; return (
@@ -34,30 +37,42 @@ export class OverviewStatuses extends React.Component {
+ { isAllowedResource("pods") &&
Pods ({pods.length})
+ } + { isAllowedResource("deployments") &&
Deployments ({deployments.length})
+ } + { isAllowedResource("statefulsets") &&
StatefulSets ({statefulSets.length})
+ } + { isAllowedResource("daemonsets") &&
DaemonSets ({daemonSets.length})
+ } + { isAllowedResource("jobs") &&
Jobs ({jobs.length})
+ } + { isAllowedResource("cronjobs") &&
CronJobs ({cronJobs.length})
+ }
) diff --git a/dashboard/client/components/+workloads-overview/overview.tsx b/dashboard/client/components/+workloads-overview/overview.tsx index f47fd7ae19..c3f08d13c2 100644 --- a/dashboard/client/components/+workloads-overview/overview.tsx +++ b/dashboard/client/components/+workloads-overview/overview.tsx @@ -16,6 +16,8 @@ import { jobStore } from "../+workloads-jobs/job.store"; import { cronJobStore } from "../+workloads-cronjobs/cronjob.store"; import { Spinner } from "../spinner"; import { Events } from "../+events"; +import { KubeObjectStore } from "../../kube-object.store"; +import { isAllowedResource } from "../../api/rbac" interface Props extends RouteComponentProps { } @@ -26,16 +28,31 @@ export class WorkloadsOverview extends React.Component { @observable isUnmounting = false; async componentDidMount() { - const stores = [ - podsStore, - deploymentStore, - daemonSetStore, - statefulSetStore, - replicaSetStore, - jobStore, - cronJobStore, - eventStore, - ]; + const stores: KubeObjectStore[] = []; + if (isAllowedResource("pods")) { + stores.push(podsStore); + } + if (isAllowedResource("deployments")) { + stores.push(deploymentStore); + } + if (isAllowedResource("daemonsets")) { + stores.push(daemonSetStore); + } + if (isAllowedResource("statefulsets")) { + stores.push(statefulSetStore); + } + if (isAllowedResource("replicasets")) { + stores.push(replicaSetStore); + } + if (isAllowedResource("jobs")) { + stores.push(jobStore); + } + if (isAllowedResource("cronjobs")) { + stores.push(cronJobStore); + } + if (isAllowedResource("events")) { + stores.push(eventStore); + } this.isReady = stores.every(store => store.isLoaded); await Promise.all(stores.map(store => store.loadAll())); this.isReady = true; @@ -55,11 +72,11 @@ export class WorkloadsOverview extends React.Component { return ( <> - + /> } ) } @@ -71,4 +88,4 @@ export class WorkloadsOverview extends React.Component { ) } -} \ No newline at end of file +} diff --git a/dashboard/client/components/+workloads/workloads.tsx b/dashboard/client/components/+workloads/workloads.tsx index 855151e72c..895f1ab3a2 100644 --- a/dashboard/client/components/+workloads/workloads.tsx +++ b/dashboard/client/components/+workloads/workloads.tsx @@ -15,6 +15,7 @@ import { DaemonSets } from "../+workloads-daemonsets"; import { StatefulSets } from "../+workloads-statefulsets"; import { Jobs } from "../+workloads-jobs"; import { CronJobs } from "../+workloads-cronjobs"; +import { isAllowedResource } from "../../api/rbac" interface Props extends RouteComponentProps { } @@ -23,50 +24,63 @@ interface Props extends RouteComponentProps { export class Workloads extends React.Component { static get tabRoutes(): TabRoute[] { const query = namespaceStore.getContextParams(); - return [ + const routes: TabRoute[] = [ { title: Overview, component: WorkloadsOverview, url: overviewURL({ query }), path: overviewRoute.path - }, - { + } + ] + if (isAllowedResource("pods")) { + routes.push({ title: Pods, component: Pods, url: podsURL({ query }), path: podsRoute.path - }, - { + }) + } + if (isAllowedResource("deployments")) { + routes.push({ title: Deployments, component: Deployments, url: deploymentsURL({ query }), path: deploymentsRoute.path, - }, - { + }) + } + if (isAllowedResource("daemonsets")) { + routes.push({ title: DaemonSets, component: DaemonSets, url: daemonSetsURL({ query }), path: daemonSetsRoute.path, - }, - { + }) + } + if (isAllowedResource("statefulsets")) { + routes.push({ title: StatefulSets, component: StatefulSets, url: statefulSetsURL({ query }), path: statefulSetsRoute.path, - }, - { + }) + } + if (isAllowedResource("jobs")) { + routes.push({ title: Jobs, component: Jobs, url: jobsURL({ query }), path: jobsRoute.path, - }, - { + }) + } + if (isAllowedResource("cronjobs")) { + routes.push({ title: CronJobs, component: CronJobs, url: cronJobsURL({ query }), path: cronJobsRoute.path, - }, - ] + }) + } + return routes; }; render() { @@ -80,4 +94,4 @@ export class Workloads extends React.Component { ) } -} \ No newline at end of file +} diff --git a/dashboard/client/components/app.tsx b/dashboard/client/components/app.tsx index 516c2d4903..220409ec12 100755 --- a/dashboard/client/components/app.tsx +++ b/dashboard/client/components/app.tsx @@ -32,6 +32,7 @@ import { PodLogsDialog } from "./+workloads-pods/pod-logs-dialog"; import { DeploymentScaleDialog } from "./+workloads-deployments/deployment-scale-dialog"; import { CustomResources } from "./+custom-resources/custom-resources"; import { crdRoute } from "./+custom-resources"; +import { isAllowedResource } from "../api/rbac"; @observer class App extends React.Component { @@ -46,7 +47,7 @@ class App extends React.Component { }; render() { - const homeUrl = clusterURL(); + const homeUrl = (isAllowedResource(["events", "nodes", "pods"])) ? clusterURL() : workloadsURL(); return ( diff --git a/dashboard/client/components/error-boundary/error-boundary.tsx b/dashboard/client/components/error-boundary/error-boundary.tsx index 378f381e36..c30d858e30 100644 --- a/dashboard/client/components/error-boundary/error-boundary.tsx +++ b/dashboard/client/components/error-boundary/error-boundary.tsx @@ -38,7 +38,7 @@ export class ErrorBoundary extends React.Component { render() { const { error, errorInfo } = this.state; if (error) { - const slackLink = Slack + const slackLink = Slack const githubLink = Github const pageUrl = location.href; return ( @@ -72,4 +72,4 @@ export class ErrorBoundary extends React.Component { } return this.props.children; } -} \ No newline at end of file +} diff --git a/dashboard/client/components/item-object-list/item-list-layout.tsx b/dashboard/client/components/item-object-list/item-list-layout.tsx index 06cda88045..eb5dac108c 100644 --- a/dashboard/client/components/item-object-list/item-list-layout.tsx +++ b/dashboard/client/components/item-object-list/item-list-layout.tsx @@ -114,10 +114,15 @@ export class ItemListLayout extends React.Component { const { store, dependentStores, isClusterScoped } = this.props; const stores = [store, ...dependentStores]; if (!isClusterScoped) stores.push(namespaceStore); - await Promise.all(stores.map(store => store.loadAll())); - const subscriptions = stores.map(store => store.subscribe()); + try { + await Promise.all(stores.map(store => store.loadAll())); + const subscriptions = stores.map(store => store.subscribe()); + + subscriptions.forEach(dispose => dispose()); // unsubscribe all + } catch(error) { + console.log("catched", error) + } await when(() => this.isUnmounting); - subscriptions.forEach(dispose => dispose()); // unsubscribe all } componentWillUnmount() { diff --git a/dashboard/client/components/layout/sidebar.tsx b/dashboard/client/components/layout/sidebar.tsx index e005ecd87c..5cd77c6746 100644 --- a/dashboard/client/components/layout/sidebar.tsx +++ b/dashboard/client/components/layout/sidebar.tsx @@ -28,6 +28,7 @@ import { crdStore } from "../+custom-resources/crd.store"; import { CrdList, crdResourcesRoute, crdRoute, crdURL } from "../+custom-resources"; import { CustomResources } from "../+custom-resources/custom-resources"; import { navigation } from "../../navigation"; +import { isAllowedResource } from "../../api/rbac" const SidebarContext = React.createContext({ pinned: false }); type SidebarContextValue = { @@ -43,7 +44,7 @@ interface Props { @observer export class Sidebar extends React.Component { async componentDidMount() { - if (!crdStore.isLoaded) crdStore.loadAll() + if (!crdStore.isLoaded && isAllowedResource('customresourcedefinitions')) crdStore.loadAll() } renderCustomResources() { @@ -71,7 +72,7 @@ export class Sidebar extends React.Component { render() { const { toggle, isPinned, className } = this.props; - const { isClusterAdmin, allowedResources } = configStore; + const { allowedResources } = configStore; const query = namespaceStore.getContextParams(); return ( @@ -91,19 +92,21 @@ export class Sidebar extends React.Component {
Cluster} icon={} /> Nodes} icon={} /> { /> { /> { /> { /> } text={Namespaces} /> } @@ -165,7 +173,7 @@ export class Sidebar extends React.Component { /> =0.5.1: websocket-extensions ">=0.1.1" websocket-extensions@>=0.1.1: - version "0.1.3" - resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.3.tgz#5d2ff22977003ec687a4b87073dfbbac146ccf29" - integrity sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg== + version "0.1.4" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" + integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3: version "1.0.5" diff --git a/package.json b/package.json index 26de7c1c83..653a55cc59 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "copyright": "© 2020, Lakend Labs, Inc.", "license": "MIT", "description": "Lens - The Kubernetes IDE", - "version": "3.4.0", + "version": "3.5.0-beta.1", "main": "main.ts", "config": { "bundledKubectlVersion": "1.17.4", @@ -109,16 +109,17 @@ "dev-dashboard": "cd dashboard && yarn dev", "dev-electron": "electron-webpack dev", "compile": "yarn download:bins && electron-webpack", - "build:linux": "yarn compile && electron-builder --linux --dir", - "build:mac": "yarn compile && electron-builder --mac --dir", - "build:win": "yarn compile && electron-builder --win --dir", + "build:linux": "yarn compile && electron-builder --linux --dir -c.productName=LensDev", + "build:mac": "yarn compile && electron-builder --mac --dir -c.productName=LensDev", + "build:win": "yarn compile && electron-builder --win --dir -c.productName=LensDev", "dist": "yarn compile && electron-builder -p onTag", "dist:win": "yarn compile && electron-builder -p onTag --x64 --ia32", "dist:dir": "yarn dist --dir -c.compression=store -c.mac.identity=null", "lint": "eslint $@ --ext js,ts,vue --max-warnings=0 src/", "lint-dashboard": "eslint $@ --ext js,ts,tsx --max-warnings=0 dashboard/client dashboard/server", "postinstall": "patch-package", - "test": "node_modules/.bin/jest", + "test": "node_modules/.bin/jest spec/src/", + "integration": "node_modules/.bin/jest spec/integration/", "download:bins": "concurrently \"yarn download:kubectl\" \"yarn download:helm\"", "download:kubectl": "yarn run ts-node build/download_kubectl.ts", "download:helm": "yarn run ts-node build/download_helm.ts" @@ -185,6 +186,7 @@ "@types/tempy": "0.1.0", "@types/universal-analytics": "^0.4.3", "@types/uuid": "^3.4.5", + "@types/webdriverio": "^4.13.0", "@typescript-eslint/eslint-plugin": "^2.7.0", "@typescript-eslint/parser": "^2.7.0", "bootstrap": "^4.3.1", @@ -212,6 +214,7 @@ "postinstall-postinstall": "^2.0.0", "prismjs": "^1.17.1", "sass-loader": "^8.0.0", + "spectron": "^8.0.0", "ts-jest": "^24.1.0", "ts-loader": "^6.0.4", "ts-node": "^8.4.1", diff --git a/spec/integration/helpers/utils.ts b/spec/integration/helpers/utils.ts new file mode 100644 index 0000000000..ee138be56e --- /dev/null +++ b/spec/integration/helpers/utils.ts @@ -0,0 +1,34 @@ +import { Application } from "spectron"; + +let appPath = "" +switch(process.platform) { +case "win32": + appPath = "./dist/win-unpacked/Lens.exe" + break +case "linux": + appPath = "./dist/linux-unpacked/kontena-lens" + break +case "darwin": + appPath = "./dist/mac/LensDev.app/Contents/MacOS/LensDev" + break +} + +export function setup() { + return new Application({ + // path to electron app + args: [], + path: appPath, + startTimeout: 30000, + waitTimeout: 30000, + }) +} + +export async function tearDown(app: Application) { + const pid = app.mainProcess.pid + await app.stop() + try { + process.kill(pid, 0); + } catch(e) { + return + } +} diff --git a/spec/integration/specs/app_spec.ts b/spec/integration/specs/app_spec.ts new file mode 100644 index 0000000000..414c3288bd --- /dev/null +++ b/spec/integration/specs/app_spec.ts @@ -0,0 +1,56 @@ +import { Application } from "spectron" +import * as util from "../helpers/utils" +import { spawnSync } from "child_process" +import { stat } from "fs" + +jest.setTimeout(20000) + +describe("app start", () => { + let app: Application + const clickWhatsNew = async (app: Application) => { + await app.client.waitUntilTextExists("h1", "What's new") + await app.client.click("button.btn-primary") + await app.client.waitUntilTextExists("h1", "Welcome") + } + + beforeEach(async () => { + app = util.setup() + await app.start() + const windowCount = await app.client.getWindowCount() + await app.client.windowByIndex(windowCount - 1) + await app.client.waitUntilWindowLoaded() + }, 20000) + + it('shows "whats new"', async () => { + await clickWhatsNew(app) + }) + + it('allows to add a cluster', async () => { + const status = spawnSync("minikube status", {shell: true}) + if (status.status !== 0) { + console.warn("minikube not running, skipping test") + return + } + await clickWhatsNew(app) + await app.client.click("a#add-cluster") + await app.client.waitUntilTextExists("legend", "Choose config:") + await app.client.selectByVisibleText("select#kubecontext-select", "minikube (new)") + await app.client.click("button.btn-primary") + await app.client.waitUntilTextExists("pre.auth-output", "Authentication proxy started") + let windowCount = await app.client.getWindowCount() + // wait for webview to appear on window count + while (windowCount == 1) { + windowCount = await app.client.getWindowCount() + } + await app.client.windowByIndex(windowCount - 1) + await app.client.waitUntilTextExists("span.link-text", "Cluster") + await app.client.click('a[href="/nodes"]') + await app.client.waitUntilTextExists("div.TableCell", "minikube") + }) + + afterEach(async () => { + if (app && app.isRunning()) { + return util.tearDown(app) + } + }) +}) diff --git a/src/features/metrics/14-kube-state-metrics-deployment.yml.hb b/src/features/metrics/14-kube-state-metrics-deployment.yml.hb index 575eb41e95..cb13c8112d 100644 --- a/src/features/metrics/14-kube-state-metrics-deployment.yml.hb +++ b/src/features/metrics/14-kube-state-metrics-deployment.yml.hb @@ -23,11 +23,19 @@ spec: operator: In values: - linux + - key: kubernetes.io/arch + operator: In + values: + - amd64 - matchExpressions: - key: beta.kubernetes.io/os operator: In values: - linux + - key: beta.kubernetes.io/arch + operator: In + values: + - amd64 serviceAccountName: kube-state-metrics containers: - name: kube-state-metrics diff --git a/src/main/menu.ts b/src/main/menu.ts index f834d33cbc..75635627bc 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -183,7 +183,7 @@ export default function initMenu(opts: MenuOptions, promiseIpc: any) { { label: 'Community Slack', click: async () => { - shell.openExternal('https://join.slack.com/t/kontenacommunity/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI'); + shell.openExternal('https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI'); }, }, { diff --git a/src/main/routes/config.ts b/src/main/routes/config.ts index 5ac4022bf1..6becb76afb 100644 --- a/src/main/routes/config.ts +++ b/src/main/routes/config.ts @@ -5,6 +5,31 @@ import { getAppVersion } from "../../common/app-utils" import { CoreV1Api, AuthorizationV1Api } from "@kubernetes/client-node" import { Cluster } from "../cluster" +// TODO: auto-populate all resources dynamically +const apiResources = [ + { resource: "configmaps" }, + { resource: "cronjobs", group: "batch" }, + { resource: "customresourcedefinitions", group: "apiextensions.k8s.io" }, + { resource: "daemonsets", group: "apps" }, + { resource: "deployments", group: "apps" }, + { resource: "endpoints" }, + { resource: "events" }, + { resource: "horizontalpodautoscalers" }, + { resource: "ingresses", group: "networking.k8s.io" }, + { resource: "jobs", group: "batch" }, + { resource: "namespaces" }, + { resource: "networkpolicies", group: "networking.k8s.io" }, + { resource: "nodes" }, + { resource: "persistentvolumes" }, + { resource: "pods" }, + { resource: "podsecuritypolicies" }, + { resource: "resourcequotas" }, + { resource: "secrets" }, + { resource: "services" }, + { resource: "statefulsets", group: "apps" }, + { resource: "storageclasses", group: "storage.k8s.io" }, +] + async function getAllowedNamespaces(cluster: Cluster) { const api = cluster.contextHandler.kc.makeApiClient(CoreV1Api) try { @@ -30,21 +55,18 @@ async function getAllowedNamespaces(cluster: Cluster) { } } -async function getAllowedResources(cluster: Cluster) { - // TODO: auto-populate all resources dynamically - const resources = [ - "nodes", "persistentvolumes", "storageclasses", "customresourcedefinitions", - "podsecuritypolicies" - ] +async function getAllowedResources(cluster: Cluster, namespaces: string[]) { try { const resourceAccessStatuses = await Promise.all( - resources.map(resource => cluster.canI({ - resource: resource, - verb: "list" + apiResources.map(apiResource => cluster.canI({ + resource: apiResource.resource, + group: apiResource.group, + verb: "list", + namespace: namespaces[0] })) ) - return resources - .filter((resource, i) => resourceAccessStatuses[i]) + return apiResources + .filter((resource, i) => resourceAccessStatuses[i]).map(apiResource => apiResource.resource) } catch(error) { return [] } @@ -55,6 +77,7 @@ class ConfigRoute extends LensApi { public async routeConfig(request: LensApiRequest) { const { params, response, cluster} = request + const namespaces = await getAllowedNamespaces(cluster) const data = { clusterName: cluster.contextName, lensVersion: getAppVersion(), @@ -62,8 +85,8 @@ class ConfigRoute extends LensApi { kubeVersion: cluster.version, chartsEnabled: true, isClusterAdmin: cluster.isAdmin, - allowedResources: await getAllowedResources(cluster), - allowedNamespaces: await getAllowedNamespaces(cluster) + allowedResources: await getAllowedResources(cluster, namespaces), + allowedNamespaces: namespaces }; this.respondJson(response, data) diff --git a/src/main/shell-session.ts b/src/main/shell-session.ts index bc2235ad36..7efa43b8bf 100644 --- a/src/main/shell-session.ts +++ b/src/main/shell-session.ts @@ -159,7 +159,7 @@ export class ShellSession extends EventEmitter { } protected exit(code = 1000) { - this.websocket.close(code) + if (this.websocket.readyState == this.websocket.OPEN) this.websocket.close(code) this.emit('exit') } @@ -179,12 +179,25 @@ export class ShellSession extends EventEmitter { protected exitProcessOnWebsocketClose() { this.websocket.on("close", () => { - if (this.shellProcess) { - this.shellProcess.kill(); - } + this.killShellProcess() }) } + protected killShellProcess(){ + if(this.running) { + // On Windows we need to kill the shell process by pid, since Lens won't respond after a while if using `this.shellProcess.kill()` + if (process.platform == "win32") { + try { + process.kill(this.shellProcess.pid) + } catch(e) { + return + } + } else { + this.shellProcess.kill() + } + } + } + protected sendResponse(msg: string) { this.websocket.send("1" + Buffer.from(msg).toString("base64")) } diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index b585f69761..9baa5e1d4e 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -27,7 +27,10 @@ export class WindowManager { center: true, frame: false, resizable: false, - show: false + show: false, + webPreferences: { + nodeIntegration: true + } }) if (showSplash) { this.splashWindow.loadFile(path.join(__static, "/splash.html")) diff --git a/src/renderer/components/AddClusterPage.vue b/src/renderer/components/AddClusterPage.vue index 6f105a3950..74811a35b2 100644 --- a/src/renderer/components/AddClusterPage.vue +++ b/src/renderer/components/AddClusterPage.vue @@ -11,6 +11,7 @@ label="Choose config:" >