diff --git a/dashboard/src/actions/apps.ts b/dashboard/src/actions/apps.ts index d306f1bc05e..1a1cb47e113 100644 --- a/dashboard/src/actions/apps.ts +++ b/dashboard/src/actions/apps.ts @@ -1,8 +1,10 @@ import { Dispatch } from "redux"; import { createAction, getReturnOfExpression } from "typesafe-actions"; +import { App } from "../shared/App"; +import Chart from "../shared/Chart"; import { HelmRelease } from "../shared/HelmRelease"; -import { IApp, IChartVersion, IStoreState } from "../shared/types"; +import { AppConflict, IApp, IChartVersion, IStoreState, MissingChart } from "../shared/types"; export const requestApps = createAction("REQUEST_APPS"); export const receiveApps = createAction("RECEIVE_APPS", (apps: IApp[]) => { @@ -47,6 +49,7 @@ export function deleteApp(releaseName: string, namespace: string) { return async (dispatch: Dispatch): Promise => { try { await HelmRelease.delete(releaseName, namespace); + await App.waitForDeletion(releaseName); return true; } catch (e) { dispatch(errorDeleteApp(e)); @@ -82,6 +85,11 @@ export function deployChart( if (resourceVersion) { await HelmRelease.upgrade(releaseName, namespace, chartVersion, values); } else { + const releaseExists = await App.exists(releaseName); + if (releaseExists) { + dispatch(errorApps(new AppConflict("Already exists"))); + return false; + } await HelmRelease.create(releaseName, namespace, chartVersion, values); } return true; @@ -91,3 +99,29 @@ export function deployChart( } }; } + +export function migrateApp( + chartVersion: IChartVersion, + releaseName: string, + namespace: string, + values?: string, +) { + return async (dispatch: Dispatch): Promise => { + try { + const chartExists = await Chart.exists( + chartVersion.relationships.chart.data.name, + chartVersion.attributes.version, + chartVersion.relationships.chart.data.repo.name, + ); + if (!chartExists) { + dispatch(errorApps(new MissingChart("Not found"))); + return false; + } + await HelmRelease.create(releaseName, namespace, chartVersion, values); + return true; + } catch (e) { + dispatch(errorApps(e)); + return false; + } + }; +} diff --git a/dashboard/src/components/AppMigrate/AppMigrate.tsx b/dashboard/src/components/AppMigrate/AppMigrate.tsx new file mode 100644 index 00000000000..361dbe0407b --- /dev/null +++ b/dashboard/src/components/AppMigrate/AppMigrate.tsx @@ -0,0 +1,71 @@ +import * as React from "react"; + +import { RouterAction } from "react-router-redux"; +import { IApp, IChartState, IChartVersion } from "../../shared/types"; +import { IAppRepository } from "../../shared/types"; + +import MigrateForm from "../../components/MigrateForm"; + +interface IAppMigrateProps { + app: IApp; + error: Error | undefined; + namespace: string; + releaseName: string; + repos: IAppRepository[]; + selected: IChartState["selected"]; + migrateApp: ( + version: IChartVersion, + releaseName: string, + namespace: string, + values?: string, + ) => Promise; + getApp: (releaseName: string, namespace: string) => Promise; + push: (location: string) => RouterAction; + fetchRepositories: () => Promise; +} + +class AppMigrate extends React.Component { + public componentDidMount() { + const { fetchRepositories, releaseName, getApp, namespace } = this.props; + getApp(releaseName, namespace); + fetchRepositories(); + } + + public componentWillReceiveProps(nextProps: IAppMigrateProps) { + const { releaseName, getApp, namespace } = this.props; + if (nextProps.namespace !== namespace) { + getApp(releaseName, nextProps.namespace); + } + } + + public render() { + const { app, repos } = this.props; + if ( + !repos || + !app || + !app.data || + !app.data.chart || + !app.data.chart.metadata || + !app.data.chart.metadata.version || + !app.data.chart.values + ) { + return
Loading
; + } + return ( +
+ +
+ ); + } +} + +export default AppMigrate; diff --git a/dashboard/src/components/AppMigrate/index.tsx b/dashboard/src/components/AppMigrate/index.tsx new file mode 100644 index 00000000000..3bafeb6c4ef --- /dev/null +++ b/dashboard/src/components/AppMigrate/index.tsx @@ -0,0 +1,3 @@ +import AppMigrate from "./AppMigrate"; + +export default AppMigrate; diff --git a/dashboard/src/components/AppView/AppControls.tsx b/dashboard/src/components/AppView/AppControls.tsx index 0aa4f214313..67bffd63a1b 100644 --- a/dashboard/src/components/AppView/AppControls.tsx +++ b/dashboard/src/components/AppView/AppControls.tsx @@ -10,13 +10,17 @@ interface IAppControlsProps { } interface IAppControlsState { + migrate: boolean; modalIsOpen: boolean; redirectToAppList: boolean; upgrade: boolean; + deleting: boolean; } class AppControls extends React.Component { public state: IAppControlsState = { + deleting: false, + migrate: false, modalIsOpen: false, redirectToAppList: false, upgrade: false, @@ -24,21 +28,34 @@ class AppControls extends React.Component public render() { const { name, namespace } = this.props.app.data; + if (this.props.app.hr && this.props.app.hr.metadata) { + return ( +
+ + {this.state.upgrade && } + + + {this.state.redirectToAppList && } +
+ ); + } return (
- - {this.state.upgrade && } - - - {this.state.redirectToAppList && } + {this.state.migrate && ( + + )}
); } @@ -47,6 +64,10 @@ class AppControls extends React.Component this.setState({ upgrade: true }); }; + public handleMigrateClick = () => { + this.setState({ migrate: true }); + }; + public openModel = () => { this.setState({ modalIsOpen: true, @@ -60,6 +81,7 @@ class AppControls extends React.Component }; public handleDeleteClick = async () => { + this.setState({ deleting: true }); const deleted = await this.props.deleteApp(); const s: Partial = { modalIsOpen: false }; if (deleted) { diff --git a/dashboard/src/components/AppView/AppView.tsx b/dashboard/src/components/AppView/AppView.tsx index ab9cec84cbf..5fc7149c471 100644 --- a/dashboard/src/components/AppView/AppView.tsx +++ b/dashboard/src/components/AppView/AppView.tsx @@ -168,6 +168,7 @@ class AppView extends React.Component {
{this.props.deleteError && this.renderError(this.props.deleteError, "delete")} + {!this.props.app.hr && this.renderMigrationNeeded()}
@@ -198,6 +199,19 @@ class AppView extends React.Component { ); } + private renderMigrationNeeded() { + return ( +
+
+

+ This release is not being managed by Kubeapps. To be able to upgrade or delete this + release click on the "Migrate" button below. +

+
+
+ ); + } + private renderError(error: Error, action: string = "view") { const { namespace, releaseName } = this.props; switch (error.constructor) { diff --git a/dashboard/src/components/Config/AppRepoList/AppRepoListItem.tsx b/dashboard/src/components/Config/AppRepoList/AppRepoListItem.tsx index 988b623e2a5..8810ceda996 100644 --- a/dashboard/src/components/Config/AppRepoList/AppRepoListItem.tsx +++ b/dashboard/src/components/Config/AppRepoList/AppRepoListItem.tsx @@ -31,6 +31,7 @@ export class AppRepoListItem extends React.Component diff --git a/dashboard/src/components/ConfirmDialog/index.tsx b/dashboard/src/components/ConfirmDialog/index.tsx index 62227f0c7cf..c36a2df9f63 100644 --- a/dashboard/src/components/ConfirmDialog/index.tsx +++ b/dashboard/src/components/ConfirmDialog/index.tsx @@ -3,6 +3,7 @@ import * as Modal from "react-modal"; interface IConfirmDialogProps { modalIsOpen: boolean; + loading: boolean; onConfirm: () => Promise; closeModal: () => Promise; } @@ -39,19 +40,23 @@ class ConfirmDialog extends React.Component{this.state.error}
)} -
Are you sure you want to delete this?
-
- - -
+ {this.props.loading === true ? ( +
Loading ...
+ ) : ( +
+
Are you sure you want to delete this?
+ + +
+ )}
); diff --git a/dashboard/src/components/DeploymentForm/DeploymentForm.tsx b/dashboard/src/components/DeploymentForm/DeploymentForm.tsx index 9b790c02a79..20bb7de7cab 100644 --- a/dashboard/src/components/DeploymentForm/DeploymentForm.tsx +++ b/dashboard/src/components/DeploymentForm/DeploymentForm.tsx @@ -4,6 +4,7 @@ import { RouterAction } from "react-router-redux"; import { IServiceBinding } from "../../shared/ServiceBinding"; import { + AppConflict, ForbiddenError, IChartState, IChartVersion, @@ -89,7 +90,7 @@ class DeploymentForm extends React.Component + ); case ForbiddenError: return ( diff --git a/dashboard/src/components/FunctionView/FunctionControls.tsx b/dashboard/src/components/FunctionView/FunctionControls.tsx index d4200fb1279..c67245fd14d 100644 --- a/dashboard/src/components/FunctionView/FunctionControls.tsx +++ b/dashboard/src/components/FunctionView/FunctionControls.tsx @@ -38,6 +38,7 @@ class FunctionControls extends React.Component diff --git a/dashboard/src/components/MigrateForm/MigrateForm.tsx b/dashboard/src/components/MigrateForm/MigrateForm.tsx new file mode 100644 index 00000000000..0c5128facd8 --- /dev/null +++ b/dashboard/src/components/MigrateForm/MigrateForm.tsx @@ -0,0 +1,213 @@ +import * as React from "react"; +import { RouterAction } from "react-router-redux"; +import { IAppRepository } from "../../shared/types"; + +import { + ForbiddenError, + IChartAttributes, + IChartVersion, + IRBACRole, + IRepo, + MissingChart, + NotFoundError, +} from "../../shared/types"; +import { NotFoundErrorAlert, PermissionsErrorAlert, UnexpectedErrorAlert } from "../ErrorAlert"; + +import "brace/mode/yaml"; +import "brace/theme/xcode"; + +const RequiredRBACRoles: IRBACRole[] = [ + { + apiGroup: "helm.bitnami.com", + resource: "helmreleases", + verbs: ["create", "patch"], + }, + { + apiGroup: "kubeapps.com", + namespace: "kubeapps", + resource: "apprepositories", + verbs: ["get"], + }, +]; + +interface IMigrationFormProps { + chartID: string; + chartVersion: string; + error: Error | undefined; + migrateApp: ( + version: IChartVersion, + releaseName: string, + namespace: string, + values?: string, + ) => Promise; + push: (location: string) => RouterAction; + namespace: string; + releaseName: string; + chartValues: string | null | undefined; + chartName: string; + chartRepoAuth: {}; + chartRepoName: string; + chartRepoURL: string; + repos: IAppRepository[]; +} + +interface IMigrationtFormState { + chartRepoName: string; + chartRepoURL: string; + chartRepoAuth: {}; +} + +class MigrateForm extends React.Component { + public state: IMigrationtFormState = { + chartRepoAuth: {}, + chartRepoName: this.props.chartRepoName, + chartRepoURL: "", + }; + + public render() { + return ( +
+
+
+
{this.props.error && this.renderError()}
+
+

{this.props.chartID}

+
+
+

+ In order to be able to manage {this.props.releaseName} select the repository it can + be retrieved from. +

+
+
+
+ + +
+
+ + +
+
+

+ {" "} + * If the repository containing {this.props.chartName} is not in the list add it{" "} + here .{" "} +

+
+
+ +
+
+
+
+
+ ); + } + + public handleDeploy = async (e: React.FormEvent) => { + e.preventDefault(); + const chartRepo = { + auth: this.state.chartRepoAuth, + name: this.state.chartRepoName, + url: this.state.chartRepoURL, + } as IRepo; + const chartData = { + name: this.props.chartName, + repo: chartRepo, + } as IChartAttributes; + const version = { + attributes: { + version: this.props.chartVersion, + }, + id: this.props.chartVersion, + relationships: { + chart: { + data: chartData, + }, + }, + } as IChartVersion; + const { releaseName, namespace } = this.props; + const deployed = await this.props.migrateApp( + version, + releaseName, + namespace, + this.props.chartValues || "", + ); + if (deployed) { + this.props.push(`/apps/ns/${namespace}/${releaseName}`); + } + }; + + public handleChartRepoNameChange = (e: React.ChangeEvent) => { + let repoURL = ""; + let auth = {}; + this.props.repos.forEach(r => { + if (r.metadata.name === e.currentTarget.value && r.spec) { + repoURL = r.spec.url; + auth = r.spec.auth; + } + }); + this.setState({ + chartRepoAuth: auth, + chartRepoName: e.currentTarget.value, + chartRepoURL: repoURL, + }); + }; + public handleChartRepoURLChange = (e: React.FormEvent) => { + this.setState({ chartRepoURL: e.currentTarget.value }); + }; + + private renderError() { + const { error, namespace, releaseName } = this.props; + const roles = RequiredRBACRoles; + roles[0].verbs = ["create"]; + switch (error && error.constructor) { + case MissingChart: + return ( + + ); + case ForbiddenError: + return ( + + ); + case NotFoundError: + return ( + + ); + default: + return ; + } + } +} + +export default MigrateForm; diff --git a/dashboard/src/components/MigrateForm/index.tsx b/dashboard/src/components/MigrateForm/index.tsx new file mode 100644 index 00000000000..4004de1884f --- /dev/null +++ b/dashboard/src/components/MigrateForm/index.tsx @@ -0,0 +1,3 @@ +import MigrateForm from "./MigrateForm"; + +export default MigrateForm; diff --git a/dashboard/src/containers/AppEditContainer/AppEditContainer.tsx b/dashboard/src/containers/AppEditContainer/AppEditContainer.tsx index c29b50234f3..cfcb855733c 100644 --- a/dashboard/src/containers/AppEditContainer/AppEditContainer.tsx +++ b/dashboard/src/containers/AppEditContainer/AppEditContainer.tsx @@ -40,7 +40,7 @@ function mapDispatchToProps(dispatch: Dispatch) { ) => dispatch(actions.apps.deployChart(version, releaseName, namespace, values, resourceVersion)), fetchChartVersions: (id: string) => dispatch(actions.charts.fetchChartVersions(id)), - getApp: (r: string, ns: string) => dispatch(actions.apps.getApp(r, ns)), + getApp: (releaseName: string, ns: string) => dispatch(actions.apps.getApp(releaseName, ns)), getBindings: (ns: string) => dispatch(actions.catalog.getBindings(ns)), getChartValues: (id: string, version: string) => dispatch(actions.charts.getChartValues(id, version)), diff --git a/dashboard/src/containers/AppMigrateContainer/AppMigrateContainer.tsx b/dashboard/src/containers/AppMigrateContainer/AppMigrateContainer.tsx new file mode 100644 index 00000000000..d91d3d02459 --- /dev/null +++ b/dashboard/src/containers/AppMigrateContainer/AppMigrateContainer.tsx @@ -0,0 +1,46 @@ +import { connect } from "react-redux"; +import { push } from "react-router-redux"; +import { Dispatch } from "redux"; + +import actions from "../../actions"; +import AppMigrate from "../../components/AppMigrate"; +import { IChartVersion, IStoreState } from "../../shared/types"; + +interface IRouteProps { + match: { + params: { + namespace: string; + releaseName: string; + }; + }; +} + +function mapStateToProps( + { repos, apps, catalog, charts }: IStoreState, + { match: { params } }: IRouteProps, +) { + return { + app: apps.selected, + error: apps.error, + namespace: params.namespace, + releaseName: params.releaseName, + repos: repos.repos, + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + fetchRepositories: () => dispatch(actions.repos.fetchRepos()), + getApp: (releaseName: string, ns: string) => dispatch(actions.apps.getApp(releaseName, ns)), + migrateApp: ( + version: IChartVersion, + releaseName: string, + namespace: string, + values?: string, + resourceVersion?: string, + ) => dispatch(actions.apps.migrateApp(version, releaseName, namespace, values)), + push: (location: string) => dispatch(push(location)), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(AppMigrate); diff --git a/dashboard/src/containers/AppMigrateContainer/index.tsx b/dashboard/src/containers/AppMigrateContainer/index.tsx new file mode 100644 index 00000000000..0005bafe43e --- /dev/null +++ b/dashboard/src/containers/AppMigrateContainer/index.tsx @@ -0,0 +1,3 @@ +import AppMigrate from "./AppMigrateContainer"; + +export default AppMigrate; diff --git a/dashboard/src/containers/AppViewContainer/AppViewContainer.tsx b/dashboard/src/containers/AppViewContainer/AppViewContainer.tsx index 6e841d73976..451d9983ad3 100644 --- a/dashboard/src/containers/AppViewContainer/AppViewContainer.tsx +++ b/dashboard/src/containers/AppViewContainer/AppViewContainer.tsx @@ -26,8 +26,9 @@ function mapStateToProps({ apps }: IStoreState, { match: { params } }: IRoutePro function mapDispatchToProps(dispatch: Dispatch) { return { - deleteApp: (r: string, ns: string) => dispatch(actions.apps.deleteApp(r, ns)), - getApp: (r: string, ns: string) => dispatch(actions.apps.getApp(r, ns)), + deleteApp: (releaseName: string, ns: string) => + dispatch(actions.apps.deleteApp(releaseName, ns)), + getApp: (releaseName: string, ns: string) => dispatch(actions.apps.getApp(releaseName, ns)), }; } diff --git a/dashboard/src/containers/Root.tsx b/dashboard/src/containers/Root.tsx index 1e8005bd3bc..c9a1458da3b 100644 --- a/dashboard/src/containers/Root.tsx +++ b/dashboard/src/containers/Root.tsx @@ -9,6 +9,7 @@ import Layout from "../components/Layout"; import configureStore from "../store"; import AppEdit from "./AppEditContainer"; import AppList from "./AppListContainer"; +import AppMigrate from "./AppMigrateContainer"; import AppNew from "./AppNewContainer"; import AppView from "./AppViewContainer"; import ChartList from "./ChartListContainer"; @@ -34,6 +35,7 @@ class Root extends React.Component { "/apps/ns/:namespace": AppList, "/apps/ns/:namespace/:releaseName": AppView, "/apps/ns/:namespace/edit/:releaseName": AppEdit, + "/apps/ns/:namespace/migrate/:releaseName": AppMigrate, "/apps/ns/:namespace/new/:repo/:id/versions/:version": AppNew, "/charts": ChartList, "/charts/:repo": ChartList, diff --git a/dashboard/src/shared/App.ts b/dashboard/src/shared/App.ts new file mode 100644 index 00000000000..5235dbe94d2 --- /dev/null +++ b/dashboard/src/shared/App.ts @@ -0,0 +1,43 @@ +import { axios } from "./Auth"; +import { IAppConfigMap } from "./types"; + +export class App { + public static async waitForDeletion(name: string) { + const timeout = 30000; // 30s + return new Promise((resolve, reject) => { + const interval = setInterval(async () => { + const { data: { items: allConfigMaps } } = await axios.get<{ + items: IAppConfigMap[]; + }>(this.getConfigMapsLink([name])); + if (allConfigMaps.length === 0) { + clearInterval(interval); + resolve(); + } + }, 500); + setTimeout(() => { + clearInterval(interval); + reject(`Timeout after ${timeout / 1000} seconds`); + }, timeout); + }); + } + + public static async exists(releaseName: string) { + const { data: { items: allConfigMaps } } = await axios.get<{ + items: IAppConfigMap[]; + }>(this.getConfigMapsLink([releaseName])); + if (allConfigMaps.length === 0) { + return false; + } + return true; + } + + // getConfigMapsLink returns the URL for listing Helm ConfigMaps for the given + // set of release names. + public static getConfigMapsLink(releaseNames?: string[]) { + let query = ""; + if (releaseNames) { + query = `,NAME in (${releaseNames.join(",")})`; + } + return `/api/kube/api/v1/namespaces/kubeapps/configmaps?labelSelector=OWNER=TILLER${query}`; + } +} diff --git a/dashboard/src/shared/Chart.ts b/dashboard/src/shared/Chart.ts index 9cb6764f5c9..d3c3c17cbfe 100644 --- a/dashboard/src/shared/Chart.ts +++ b/dashboard/src/shared/Chart.ts @@ -15,5 +15,15 @@ export default class Chart { return data; } + public static async exists(id: string, version: string, repo: string) { + const url = `${Chart.APIEndpoint}/charts/${repo}/${id}/versions/${version}`; + try { + await axios.get(url); + } catch (e) { + return false; + } + return true; + } + private static APIEndpoint: string = "/api/chartsvc/v1"; } diff --git a/dashboard/src/shared/HelmRelease.ts b/dashboard/src/shared/HelmRelease.ts index 6b2363ebbe6..f87edd4d0db 100644 --- a/dashboard/src/shared/HelmRelease.ts +++ b/dashboard/src/shared/HelmRelease.ts @@ -1,10 +1,11 @@ import { inflate } from "pako"; import { clearInterval, setInterval } from "timers"; +import { App } from "./App"; import { AppRepository } from "./AppRepository"; import { axios } from "./Auth"; import { hapi } from "./hapi/release"; -import { IApp, IChart, IChartVersion, IHelmRelease, IHelmReleaseConfigMap } from "./types"; +import { IApp, IAppConfigMap, IChart, IChartVersion, IHelmRelease, NotFoundError } from "./types"; import * as url from "./url"; export class HelmRelease { @@ -30,6 +31,7 @@ export class HelmRelease { spec: { auth, chartName: chartAttrs.name, + releaseName, repoUrl: chartAttrs.repo.url, values, version: chartVersion.attributes.version, @@ -59,6 +61,7 @@ export class HelmRelease { spec: { auth, chartName: chartAttrs.name, + releaseName, repoUrl: chartAttrs.repo.url, values, version: chartVersion.attributes.version, @@ -73,25 +76,44 @@ export class HelmRelease { public static async delete(releaseName: string, namespace: string) { // strip namespace from release name - const hrName = releaseName.replace(new RegExp(`^${namespace}-`), ""); - const { data } = await axios.delete(this.getSelfLink(hrName, namespace)); + const { data } = await axios.delete(this.getSelfLink(releaseName, namespace)); return data; } - public static async getAllWithDetails(namespace?: string) { + public static async getAllHelmReleases(namespace?: string) { const { data: { items: helmReleaseList } } = await axios.get<{ items: IHelmRelease[] }>( this.getResourceLink(namespace), ); + return helmReleaseList; + } + + public static async getHelmRelease(releaseName: string, namespace: string) { + const helmReleaseList = await this.getAllHelmReleases(namespace); + let helmRelease = ""; + helmReleaseList.forEach(r => { + if (r.spec.releaseName === releaseName) { + helmRelease = r.metadata.name; + } + }); + return helmRelease; + } + + public static async getAllWithDetails(namespace?: string) { + const helmReleaseList = await this.getAllHelmReleases(namespace); // Convert list of HelmReleases to release name -> HelmRelease pair const helmReleaseMap = helmReleaseList.reduce((acc, hr) => { - acc[`${hr.metadata.namespace}-${hr.metadata.name}`] = hr; + const releaseName = + !hr.spec.releaseName || hr.spec.releaseName === "" + ? `${hr.metadata.name}-${hr.metadata.namespace}` + : hr.spec.releaseName; + acc[releaseName] = hr; return acc; }, new Map()); // Get the HelmReleaseConfigMaps for all HelmReleases const { data: { items: allConfigMaps } } = await axios.get<{ - items: IHelmReleaseConfigMap[]; - }>(this.getConfigMapsLink(Object.keys(helmReleaseMap))); + items: IAppConfigMap[]; + }>(App.getConfigMapsLink()); // Convert list of HelmReleaseConfigMaps to release name -> latest // HelmReleaseConfigMap pair @@ -105,35 +127,45 @@ export class HelmRelease { acc[releaseName] = cm; } return acc; - }, new Map()); + }, new Map()); // Go through all HelmReleaseConfigMaps and parse as IApp objects - const apps = Object.keys(cms).map(key => this.parseRelease(helmReleaseMap[key], cms[key])); + const releases = Object.keys(cms) + .map(key => this.parseRelease(cms[key], helmReleaseMap[key])) + // Exclude releases that are not of this namespace + .filter(app => !namespace || app.data.namespace === namespace); // Fetch charts for each app - return Promise.all(apps.map(async app => this.getChart(app))); + return await Promise.all(releases.map(rel => this.getChart(rel))); } public static async getDetails(releaseName: string, namespace: string) { - // strip namespace from release name - const hrName = releaseName.replace(new RegExp(`^${namespace}-`), ""); - const { data: hr } = await axios.get(this.getSelfLink(hrName, namespace)); + let hr; + try { + const i = await axios.get(this.getSelfLink(releaseName, namespace)); + hr = i.data; + } catch (e) { + // HelmRelease not available + } const items = await this.getDetailsWithRetry(releaseName); // Helm/Tiller will store details in a ConfigMap for each revision, // so we need to filter these out to pick the latest version - const helmConfigMap: IHelmReleaseConfigMap = items.reduce((ret, cm) => { + const helmConfigMap: IAppConfigMap = items.reduce((ret, cm) => { return this.getNewest(ret, cm); }, items[0]); - const app = this.parseRelease(hr, helmConfigMap); + const app = this.parseRelease(helmConfigMap, hr); + if (app.data.namespace !== namespace) { + throw new NotFoundError(`${releaseName} not found in ${namespace} namespace`); + } return await this.getChart(app); } private static getDetailsWithRetry(releaseName: string) { const getConfigMaps = () => { - return axios.get<{ items: IHelmReleaseConfigMap[] }>(this.getConfigMapsLink([releaseName])); + return axios.get<{ items: IAppConfigMap[] }>(App.getConfigMapsLink([releaseName])); }; - return new Promise(async (resolve, reject) => { + return new Promise(async (resolve, reject) => { let req = await getConfigMaps(); if (req.data.items.length > 0) { resolve(req.data.items); @@ -168,32 +200,26 @@ export class HelmRelease { } } - // getConfigMapsLink returns the URL for listing Helm ConfigMaps for the given - // set of release names. - private static getConfigMapsLink(releaseNames: string[]) { - return `/api/kube/api/v1/namespaces/kubeapps/configmaps?labelSelector=NAME in (${releaseNames.join( - ",", - )})`; - } - - // Takes two IHelmReleaseConfigMaps and returns the highest version - private static getNewest(cm1: IHelmReleaseConfigMap, cm2: IHelmReleaseConfigMap) { + // Takes two IAppConfigMaps and returns the highest version + private static getNewest(cm1: IAppConfigMap, cm2: IAppConfigMap) { const cm1Version = parseInt(cm1.metadata.labels.VERSION, 10); const cm2Version = parseInt(cm2.metadata.labels.VERSION, 10); return cm1Version > cm2Version ? cm1 : cm2; } // decode base64, ungzip (inflate) and parse as a protobuf message - private static parseRelease(hr: IHelmRelease, cm: IHelmReleaseConfigMap): IApp { + private static parseRelease(cm: IAppConfigMap, hr?: IHelmRelease): IApp { const protoBytes = inflate(atob(cm.data.release)); const rel = hapi.release.Release.decode(protoBytes); const app: IApp = { data: rel, type: "helm", hr }; - const repoName = hr.metadata.annotations["apprepositories.kubeapps.com/repo-name"]; - if (repoName) { - app.repo = { - name: repoName, - url: hr.spec.repoUrl, - }; + if (hr && hr.metadata) { + const repoName = hr.metadata.annotations["apprepositories.kubeapps.com/repo-name"]; + if (repoName) { + app.repo = { + name: repoName, + url: hr.spec.repoUrl, + }; + } } return app; } diff --git a/dashboard/src/shared/types.ts b/dashboard/src/shared/types.ts index b2ae7f4d0a6..9d55c710155 100644 --- a/dashboard/src/shared/types.ts +++ b/dashboard/src/shared/types.ts @@ -21,6 +21,20 @@ export class NotFoundError extends Error { } } +export class MissingChart extends Error { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class AppConflict extends Error { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} + export interface IRepo { name: string; url: string; @@ -290,6 +304,7 @@ export interface IHelmRelease { }; spec: { chartName: string; + releaseName: string; repoUrl: string; values: string; version: string; @@ -297,7 +312,7 @@ export interface IHelmRelease { } // Representation of the ConfigMaps Helm uses to store releases -export interface IHelmReleaseConfigMap { +export interface IAppConfigMap { metadata: { labels: { NAME: string; diff --git a/manifests/helm-crd.jsonnet b/manifests/helm-crd.jsonnet index 51dc6fc479e..34284e73bc0 100644 --- a/manifests/helm-crd.jsonnet +++ b/manifests/helm-crd.jsonnet @@ -14,7 +14,7 @@ local controllerOverlay = { containers+: [ kube.Container("controller") { name: "controller", - image: "bitnami/helm-crd-controller:v0.3.0", + image: "bitnami/helm-crd-controller:v0.4.0", securityContext: { readOnlyRootFilesystem: true, },