diff --git a/cmd/kubeapps-apis/buf.lock b/cmd/kubeapps-apis/buf.lock index 97e4a75f14e..ce22a7bcc9c 100644 --- a/cmd/kubeapps-apis/buf.lock +++ b/cmd/kubeapps-apis/buf.lock @@ -4,8 +4,8 @@ deps: - remote: buf.build owner: googleapis repository: googleapis - commit: 80720a488c9a414bb8d4a9f811084989 + commit: 62f35d8aed1149c291d606d958a7ce32 - remote: buf.build owner: grpc-ecosystem repository: grpc-gateway - commit: 00116f302b12478b85deb33b734e026c + commit: bc28b723cd774c32b6fbc77621518765 diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/harbor_api_v2_repo_lister.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/harbor_api_v2_repo_lister.go index dda78045c7f..65972244d96 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/harbor_api_v2_repo_lister.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/harbor_api_v2_repo_lister.go @@ -7,7 +7,6 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "net/http" "net/url" "strings" @@ -118,7 +117,7 @@ func pingHarbor(ctx context.Context, ref orasregistryv2.Reference, cred orasregi switch resp.StatusCode { case http.StatusOK: lr := io.LimitReader(resp.Body, 100) - if pong, err := ioutil.ReadAll(lr); err != nil { + if pong, err := io.ReadAll(lr); err != nil { return "", err } else if string(pong) != "Pong" { return "", fmt.Errorf("unexpected response body: %s", string(pong)) diff --git a/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server.go b/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server.go index e97621009d9..02a973a2c98 100644 --- a/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server.go +++ b/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server.go @@ -441,7 +441,7 @@ func (s *Server) hasAccessToNamespace(ctx context.Context, cluster, namespace st } if !res.Status.Allowed { // If the user has not access, return a unauthenticated response, otherwise, continue - return status.Errorf(codes.Unauthenticated, "The current user has no access to the namespace %q", namespace) + return status.Errorf(codes.PermissionDenied, "The current user has no access to the namespace %q", namespace) } return nil } @@ -690,7 +690,7 @@ func (s *Server) CreateInstalledPackage(ctx context.Context, request *corev1.Cre } ch, registrySecrets, err := s.fetchChartWithRegistrySecrets(ctx, chartDetails, typedClient) if err != nil { - return nil, status.Errorf(codes.Unauthenticated, "Missing permissions %v", err) + return nil, status.Errorf(codes.PermissionDenied, "Missing permissions %v", err) } // Create an action config for the target namespace. @@ -761,7 +761,7 @@ func (s *Server) UpdateInstalledPackage(ctx context.Context, request *corev1.Upd } ch, registrySecrets, err := s.fetchChartWithRegistrySecrets(ctx, chartDetails, typedClient) if err != nil { - return nil, status.Errorf(codes.Unauthenticated, "Missing permissions %v", err) + return nil, status.Errorf(codes.PermissionDenied, "Missing permissions %v", err) } // Create an action config for the installed pkg context. diff --git a/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server_test.go b/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server_test.go index 808879625a5..3086435142d 100644 --- a/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server_test.go +++ b/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server_test.go @@ -554,7 +554,7 @@ func TestGetAvailablePackageSummaries(t *testing.T) { statusCode: codes.Internal, }, { - name: "it returns an unauthenticated status if the user doesn't have permissions", + name: "it returns a permissionDenied status if the user doesn't have permissions", authorized: false, request: &corev1.GetAvailablePackageSummariesRequest{ Context: &corev1.Context{ @@ -562,7 +562,7 @@ func TestGetAvailablePackageSummaries(t *testing.T) { }, }, charts: []*models.Chart{{Name: "foo"}}, - statusCode: codes.Unauthenticated, + statusCode: codes.PermissionDenied, }, { name: "it returns only the requested page of results and includes the next page token", @@ -1005,7 +1005,7 @@ func TestGetAvailablePackageDetail(t *testing.T) { statusCode: codes.Internal, }, { - name: "it returns an unauthenticated status if the user doesn't have permissions", + name: "it returns a permissionDenied status if the user doesn't have permissions", authorized: false, request: &corev1.GetAvailablePackageDetailRequest{ AvailablePackageRef: &corev1.AvailablePackageReference{ @@ -1015,7 +1015,7 @@ func TestGetAvailablePackageDetail(t *testing.T) { }, charts: []*models.Chart{{Name: "foo"}}, expectedPackage: &corev1.AvailablePackageDetail{}, - statusCode: codes.Unauthenticated, + statusCode: codes.PermissionDenied, }, } diff --git a/dashboard/src/actions/auth.test.tsx b/dashboard/src/actions/auth.test.tsx index 0cbefdba2d3..33b3990b75b 100644 --- a/dashboard/src/actions/auth.test.tsx +++ b/dashboard/src/actions/auth.test.tsx @@ -30,9 +30,7 @@ beforeEach(() => { Auth.isAuthenticatedWithCookie = jest.fn().mockReturnValue("token"); Auth.setAuthToken = jest.fn(); Auth.unsetAuthToken = jest.fn(); - Namespace.list = jest.fn(async () => { - return { namespaceNames: [] }; - }); + Namespace.list = jest.fn(async () => []); jest.spyOn(NS, "unsetStoredNamespace"); store = mockStore({ diff --git a/dashboard/src/actions/auth.ts b/dashboard/src/actions/auth.ts index 98c23cfe773..d8f0c1d5676 100644 --- a/dashboard/src/actions/auth.ts +++ b/dashboard/src/actions/auth.ts @@ -4,7 +4,7 @@ import { ThunkAction } from "redux-thunk"; import { Auth } from "shared/Auth"; import * as Namespace from "shared/Namespace"; -import { IStoreState } from "shared/types"; +import { IStoreState, UnauthorizedNetworkError } from "shared/types"; import { ActionType, deprecated } from "typesafe-actions"; import { clearClusters, NamespaceAction } from "./namespace"; @@ -71,6 +71,27 @@ export function logout(): ThunkAction< }; } +export function logoutByAuthenticationError(): ThunkAction< + Promise, + IStoreState, + null, + AuthAction | NamespaceAction +> { + return async dispatch => { + dispatch(logout()); + dispatch(authenticationError("Unauthorized")); + dispatch(expireSession()); + }; +} + +export function handleErrorAction(error: any, action?: ActionType) { + if (error.constructor === UnauthorizedNetworkError) { + return logoutByAuthenticationError(); + } else if (action) { + return action; + } +} + export function expireSession(): ThunkAction, IStoreState, null, AuthAction> { return async dispatch => { if (Auth.usingOIDCToken()) { diff --git a/dashboard/src/actions/availablepackages.ts b/dashboard/src/actions/availablepackages.ts index 0cba9db8879..a67e8908d86 100644 --- a/dashboard/src/actions/availablepackages.ts +++ b/dashboard/src/actions/availablepackages.ts @@ -14,6 +14,7 @@ import { IReceivePackagesActionPayload as IReceiveAvailablePackageSummariesActionPayload, IStoreState, } from "../shared/types"; +import { handleErrorAction } from "./auth"; const { createAction } = deprecated; @@ -126,7 +127,7 @@ export function fetchAvailablePackageSummaries( ); dispatch(receiveAvailablePackageSummaries({ response, paginationToken })); } catch (e: any) { - dispatch(createErrorPackage(new FetchError(e.message))); + dispatch(handleErrorAction(e, createErrorPackage(new FetchError(e.message)))); } }; } @@ -140,7 +141,7 @@ export function fetchAvailablePackageVersions( const response = await PackagesService.getAvailablePackageVersions(availablePackageReference); dispatch(receiveSelectedAvailablePackageVersions(response)); } catch (e: any) { - dispatch(createErrorPackage(new FetchError(e.message))); + dispatch(handleErrorAction(e, createErrorPackage(new FetchError(e.message)))); } }; } @@ -162,7 +163,7 @@ export function fetchAndSelectAvailablePackageDetail( dispatch(createErrorPackage(new FetchError("could not find package version"))); } } catch (e: any) { - dispatch(createErrorPackage(new FetchError(e.message))); + dispatch(handleErrorAction(e, createErrorPackage(new FetchError(e.message)))); } }; } diff --git a/dashboard/src/actions/installedpackages.test.tsx b/dashboard/src/actions/installedpackages.test.tsx index 205fa847907..66cefb77cd8 100644 --- a/dashboard/src/actions/installedpackages.test.tsx +++ b/dashboard/src/actions/installedpackages.test.tsx @@ -15,7 +15,7 @@ import { import { Plugin } from "gen/kubeappsapis/core/plugins/v1alpha1/plugins"; import { InstalledPackage } from "shared/InstalledPackage"; import { getStore, initialState } from "shared/specs/mountWrapper"; -import { IStoreState, PluginNames, UnprocessableEntity, UpgradeError } from "shared/types"; +import { IStoreState, PluginNames, UnprocessableEntityError, UpgradeError } from "shared/types"; import { getType } from "typesafe-actions"; import actions from "."; @@ -234,7 +234,7 @@ describe("deploy package", () => { { type: getType(actions.installedpackages.requestInstallPackage) }, { type: getType(actions.installedpackages.errorInstalledPackage), - payload: new UnprocessableEntity( + payload: new UnprocessableEntityError( "The given values don't match the required format. The following errors were found:\n - /foo: must be string", ), }, @@ -313,7 +313,7 @@ describe("updateInstalledPackage", () => { { type: getType(actions.installedpackages.requestUpdateInstalledPackage) }, { type: getType(actions.installedpackages.errorInstalledPackage), - payload: new UnprocessableEntity( + payload: new UnprocessableEntityError( "The given values don't match the required format. The following errors were found:\n - /foo: must be string", ), }, diff --git a/dashboard/src/actions/installedpackages.ts b/dashboard/src/actions/installedpackages.ts index b6d63a49016..3ad8503ae90 100644 --- a/dashboard/src/actions/installedpackages.ts +++ b/dashboard/src/actions/installedpackages.ts @@ -21,13 +21,14 @@ import { FetchWarning, IStoreState, RollbackError, - UnprocessableEntity, + UnprocessableEntityError, UpgradeError, } from "shared/types"; import { getPluginsSupportingRollback } from "shared/utils"; import { ActionType, deprecated } from "typesafe-actions"; import { InstalledPackage } from "../shared/InstalledPackage"; import { validate } from "../shared/schema"; +import { handleErrorAction } from "./auth"; const { createAction } = deprecated; @@ -129,16 +130,24 @@ export function getInstalledPackage( availablePackageDetail = resp.availablePackageDetail; } catch (e: any) { dispatch( - errorInstalledPackage( - new FetchWarning( - "this package has missing information, some actions might not be available.", + handleErrorAction( + e, + errorInstalledPackage( + new FetchWarning( + "this package has missing information, some actions might not be available.", + ), ), ), ); } dispatch(selectInstalledPackage(installedPackageDetail!, availablePackageDetail)); } catch (e: any) { - dispatch(errorInstalledPackage(new FetchError("Unable to get installed package", [e]))); + dispatch( + handleErrorAction( + e, + errorInstalledPackage(new FetchError("Unable to get installed package", [e])), + ), + ); } }; } @@ -162,7 +171,12 @@ export function getInstalledPkgStatus( ); dispatch(receiveInstalledPackageStatus(installedPackageDetail!.status!)); } catch (e: any) { - dispatch(errorInstalledPackage(new FetchError("Unable to refresh installed package", [e]))); + dispatch( + handleErrorAction( + e, + errorInstalledPackage(new FetchError("Unable to refresh installed package", [e])), + ), + ); } } }; @@ -178,7 +192,7 @@ export function deleteInstalledPackage( dispatch(receiveDeleteInstalledPackage()); return true; } catch (e: any) { - dispatch(errorInstalledPackage(new DeleteError(e.message))); + dispatch(handleErrorAction(e, errorInstalledPackage(new DeleteError(e.message)))); return false; } }; @@ -199,7 +213,9 @@ export function fetchInstalledPackages( dispatch(receiveInstalledPackageList(installedPackageSummaries)); return installedPackageSummaries; } catch (e: any) { - dispatch(errorInstalledPackage(new FetchError("Unable to list apps", [e]))); + dispatch( + handleErrorAction(e, errorInstalledPackage(new FetchError("Unable to list apps", [e]))), + ); return []; } }; @@ -223,7 +239,7 @@ export function installPackage( const errorText = validation.errors && validation.errors.map(e => ` - ${e.instancePath}: ${e.message}`).join("\n"); - throw new UnprocessableEntity( + throw new UnprocessableEntityError( `The given values don't match the required format. The following errors were found:\n${errorText}`, ); } @@ -251,7 +267,7 @@ export function installPackage( return false; } } catch (e: any) { - dispatch(errorInstalledPackage(new CreateError(e.message))); + dispatch(handleErrorAction(e, errorInstalledPackage(new CreateError(e.message)))); return false; } }; @@ -272,7 +288,7 @@ export function updateInstalledPackage( const errorText = validation.errors && validation.errors.map(e => ` - ${e.instancePath}: ${e.message}`).join("\n"); - throw new UnprocessableEntity( + throw new UnprocessableEntityError( `The given values don't match the required format. The following errors were found:\n${errorText}`, ); } @@ -294,7 +310,7 @@ export function updateInstalledPackage( return false; } } catch (e: any) { - dispatch(errorInstalledPackage(new UpgradeError(e.message))); + dispatch(handleErrorAction(e, errorInstalledPackage(new UpgradeError(e.message)))); return false; } }; @@ -317,7 +333,7 @@ export function rollbackInstalledPackage( dispatch(getInstalledPackage(installedPackageRef)); return true; } catch (e: any) { - dispatch(errorInstalledPackage(new RollbackError(e.message))); + dispatch(handleErrorAction(e, errorInstalledPackage(new RollbackError(e.message)))); return false; } } else { diff --git a/dashboard/src/actions/namespace.test.tsx b/dashboard/src/actions/namespace.test.tsx index 64183287b5d..ef6a9ef357b 100644 --- a/dashboard/src/actions/namespace.test.tsx +++ b/dashboard/src/actions/namespace.test.tsx @@ -71,9 +71,7 @@ actionTestCases.forEach(tc => { describe("fetchNamespaces", () => { it("dispatches the list of namespace names if no error", async () => { Namespace.list = jest.fn().mockImplementationOnce(() => { - return { - namespaceNames: ["overlook-hotel", "room-217"], - }; + return ["overlook-hotel", "room-217"]; }); const expectedActions = [ { @@ -88,7 +86,7 @@ describe("fetchNamespaces", () => { it("dispatches errorNamespace if the request returns no 'namespaces'", async () => { Namespace.list = jest.fn().mockImplementationOnce(() => { - return {}; + return []; }); const err = new Error("The current account does not have access to any namespaces"); const expectedActions = [ @@ -122,9 +120,7 @@ describe("createNamespace", () => { it("dispatches the new namespace and re-fetch namespaces", async () => { Namespace.create = jest.fn(); Namespace.list = jest.fn().mockImplementationOnce(() => { - return { - namespaceNames: ["overlook-hotel", "room-217"], - }; + return ["overlook-hotel", "room-217"]; }); const expectedActions = [ { diff --git a/dashboard/src/actions/namespace.ts b/dashboard/src/actions/namespace.ts index e99b31fa145..24d8e55978a 100644 --- a/dashboard/src/actions/namespace.ts +++ b/dashboard/src/actions/namespace.ts @@ -6,6 +6,7 @@ import { Kube } from "shared/Kube"; import Namespace, { setStoredNamespace } from "shared/Namespace"; import { IStoreState } from "shared/types"; import { ActionType, deprecated } from "typesafe-actions"; +import { handleErrorAction } from "./auth"; const { createAction } = deprecated; @@ -56,7 +57,7 @@ export function fetchNamespaces( return async dispatch => { try { const namespaceList = await Namespace.list(cluster); - if (!namespaceList.namespaceNames || namespaceList.namespaceNames.length === 0) { + if (!namespaceList || namespaceList.length === 0) { dispatch( errorNamespaces( cluster, @@ -66,10 +67,10 @@ export function fetchNamespaces( ); return []; } - dispatch(receiveNamespaces(cluster, namespaceList.namespaceNames)); - return namespaceList.namespaceNames; + dispatch(receiveNamespaces(cluster, namespaceList)); + return namespaceList; } catch (e: any) { - dispatch(errorNamespaces(cluster, e, "list")); + dispatch(handleErrorAction(e, errorNamespaces(cluster, e, "list"))); return []; } }; @@ -87,7 +88,7 @@ export function createNamespace( dispatch(fetchNamespaces(cluster)); return true; } catch (e: any) { - dispatch(errorNamespaces(cluster, e, "create")); + dispatch(handleErrorAction(e, errorNamespaces(cluster, e, "create"))); return false; } }; @@ -106,7 +107,7 @@ export function checkNamespaceExists( } return exists; } catch (e: any) { - dispatch(errorNamespaces(cluster, e, "get")); + dispatch(handleErrorAction(e, errorNamespaces(cluster, e, "get"))); return false; } }; diff --git a/dashboard/src/actions/repos.test.tsx b/dashboard/src/actions/repos.test.tsx index 71fa7ec3b12..3aeb981f4cd 100644 --- a/dashboard/src/actions/repos.test.tsx +++ b/dashboard/src/actions/repos.test.tsx @@ -27,7 +27,7 @@ import { initialState } from "shared/specs/mountWrapper"; import { IPkgRepoFormData, IStoreState, - NotFoundError, + NotFoundNetworkError, PluginNames, RepositoryStorageTypes, } from "shared/types"; @@ -804,7 +804,7 @@ describe("findPackageInRepo", () => { { type: getType(repoActions.errorRepos), payload: { - err: new NotFoundError( + err: new NotFoundNetworkError( "Package my-repo/my-package not found in the repository other-namespace.", ), op: "fetch", diff --git a/dashboard/src/actions/repos.ts b/dashboard/src/actions/repos.ts index 6a0badc4264..6bca4d5a50a 100644 --- a/dashboard/src/actions/repos.ts +++ b/dashboard/src/actions/repos.ts @@ -14,8 +14,9 @@ import { uniqBy } from "lodash"; import { ThunkAction } from "redux-thunk"; import { PackageRepositoriesService } from "shared/PackageRepositoriesService"; import PackagesService from "shared/PackagesService"; -import { IPkgRepoFormData, IStoreState, NotFoundError } from "shared/types"; +import { IPkgRepoFormData, IStoreState, NotFoundNetworkError } from "shared/types"; import { ActionType, deprecated } from "typesafe-actions"; +import { handleErrorAction } from "./auth"; const { createAction } = deprecated; @@ -96,7 +97,7 @@ export const fetchRepoSummaries = ( dispatch(receiveRepoSummaries(totalRepos)); } } catch (e: any) { - dispatch(errorRepos(e, "fetch")); + dispatch(handleErrorAction(e, errorRepos(e, "fetch"))); } }; }; @@ -123,7 +124,7 @@ export const fetchRepoDetail = ( dispatch(receiveRepoDetail(getPackageRepositoryDetailResponse.detail)); return true; } catch (e: any) { - dispatch(errorRepos(e, "fetch")); + dispatch(handleErrorAction(e, errorRepos(e, "fetch"))); return false; } }; @@ -173,7 +174,7 @@ export const addRepo = ( dispatch(addedRepo(repoSummary)); return true; } catch (e: any) { - dispatch(errorRepos(e, "create")); + dispatch(handleErrorAction(e, errorRepos(e, "create"))); return false; } }; @@ -222,7 +223,7 @@ export const updateRepo = ( dispatch(repoUpdated(repoSummary)); return true; } catch (e: any) { - dispatch(errorRepos(e, "update")); + dispatch(handleErrorAction(e, errorRepos(e, "update"))); return false; } }; @@ -236,7 +237,7 @@ export const deleteRepo = ( await PackageRepositoriesService.deletePackageRepository(packageRepoRef); return true; } catch (e: any) { - dispatch(errorRepos(e, "delete")); + dispatch(handleErrorAction(e, errorRepos(e, "delete"))); return false; } }; @@ -267,7 +268,7 @@ export const findPackageInRepo = ( if (!getPackageRepositoryDetailResponse?.detail) { dispatch( errorRepos( - new NotFoundError( + new NotFoundNetworkError( `Package ${app.availablePackageRef.identifier} not found in the repository ${repoNamespace}.`, ), "fetch", @@ -279,11 +280,14 @@ export const findPackageInRepo = ( return true; } catch (e: any) { dispatch( - errorRepos( - new NotFoundError( - `Package ${app.availablePackageRef.identifier} not found in the repository ${repoNamespace}.`, + handleErrorAction( + e, + errorRepos( + new NotFoundNetworkError( + `Package ${app.availablePackageRef.identifier} not found in the repository ${repoNamespace}.`, + ), + "fetch", ), - "fetch", ), ); return false; @@ -291,7 +295,7 @@ export const findPackageInRepo = ( } else { dispatch( errorRepos( - new NotFoundError( + new NotFoundNetworkError( `The installed application '${app?.name}' does not have any matching package in the repository '${repoName}'. Are you sure you installed this application from a repository?`, ), "fetch", diff --git a/dashboard/src/components/AppView/AppView.tsx b/dashboard/src/components/AppView/AppView.tsx index b686d0b67f7..9e1c4f9787e 100644 --- a/dashboard/src/components/AppView/AppView.tsx +++ b/dashboard/src/components/AppView/AppView.tsx @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { CdsButton } from "@cds/react/button"; -import { grpc } from "@improbable-eng/grpc-web"; import actions from "actions"; +import { handleErrorAction } from "actions/auth"; import ErrorAlert from "components/ErrorAlert"; import Alert from "components/js/Alert"; import Column from "components/js/Column"; @@ -15,6 +15,7 @@ import { ResourceRef, } from "gen/kubeappsapis/core/packages/v1alpha1/packages"; import { Plugin } from "gen/kubeappsapis/core/plugins/v1alpha1/plugins"; +import placeholder from "icons/placeholder.svg"; import * as yaml from "js-yaml"; import { useEffect, useMemo, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; @@ -29,10 +30,10 @@ import { FetchError, FetchWarning, IStoreState, + NotFoundNetworkError, } from "shared/types"; import { getPluginsSupportingRollback } from "shared/utils"; import ApplicationStatus from "../../containers/ApplicationStatusContainer"; -import placeholder from "icons/placeholder.svg"; import * as url from "../../shared/url"; import LoadingWrapper from "../LoadingWrapper/LoadingWrapper"; import AccessURLTable from "./AccessURLTable/AccessURLTable"; @@ -208,8 +209,9 @@ export default function AppView() { setResourceRefs(response.resourceRefs); return; } catch (e: any) { - if (e.code !== grpc.Code.NotFound) { + if (e.constructor !== NotFoundNetworkError) { // If we get any other error, we want the user to know about it. + dispatch(handleErrorAction(e)); setFetchError(new FetchError("unable to fetch resource references", [e])); return; } @@ -223,7 +225,7 @@ export default function AppView() { return () => { abort = true; }; - }, [installedPkgRef]); + }, [dispatch, installedPkgRef]); useEffect(() => { if (resourceRefs.length === 0) { diff --git a/dashboard/src/components/Catalog/Catalog.test.tsx b/dashboard/src/components/Catalog/Catalog.test.tsx index b82178253ec..45f0de89a5c 100644 --- a/dashboard/src/components/Catalog/Catalog.test.tsx +++ b/dashboard/src/components/Catalog/Catalog.test.tsx @@ -471,7 +471,7 @@ describe("filters by application type", () => { }; const wrapper = mountWrapper( getStore({ ...populatedState, packages: packages } as IStoreState), - + diff --git a/dashboard/src/components/Catalog/Catalog.tsx b/dashboard/src/components/Catalog/Catalog.tsx index cb103f09b0c..012a4245466 100644 --- a/dashboard/src/components/Catalog/Catalog.tsx +++ b/dashboard/src/components/Catalog/Catalog.tsx @@ -291,7 +291,9 @@ export default function Catalog() { .filter( c => filters[filterNames.PKG_TYPE].length === 0 || - filters[filterNames.PKG_TYPE].includes(getPluginPackageName(c.availablePackageRef?.plugin)), + filters[filterNames.PKG_TYPE].includes( + getPluginPackageName(c.availablePackageRef?.plugin, true), + ), ) .filter(() => filters[filterNames.OPERATOR_PROVIDER].length === 0) .filter( diff --git a/dashboard/src/components/DeploymentForm/DeploymentForm.test.tsx b/dashboard/src/components/DeploymentForm/DeploymentForm.test.tsx index c43c92b7d5b..6b312494f4e 100644 --- a/dashboard/src/components/DeploymentForm/DeploymentForm.test.tsx +++ b/dashboard/src/components/DeploymentForm/DeploymentForm.test.tsx @@ -179,6 +179,7 @@ describe("renders an error", () => { then: jest.fn((f: any) => f({ serviceaccountNames: ["my-sa-1", "my-sa-2"] } as GetServiceAccountNamesResponse), ), + catch: jest.fn(f => f()), }); const wrapper = mountWrapper( diff --git a/dashboard/src/components/DeploymentForm/DeploymentForm.tsx b/dashboard/src/components/DeploymentForm/DeploymentForm.tsx index 8c756949382..571f0a6d811 100644 --- a/dashboard/src/components/DeploymentForm/DeploymentForm.tsx +++ b/dashboard/src/components/DeploymentForm/DeploymentForm.tsx @@ -5,6 +5,7 @@ import { CdsControlMessage, CdsFormGroup } from "@cds/react/forms"; import { CdsInput } from "@cds/react/input"; import { CdsSelect } from "@cds/react/select"; import actions from "actions"; +import { handleErrorAction } from "actions/auth"; import AvailablePackageDetailExcerpt from "components/Catalog/AvailablePackageDetailExcerpt"; import Alert from "components/js/Alert"; import Column from "components/js/Column"; @@ -100,9 +101,11 @@ export default function DeploymentForm() { // Populate the service account list if the plugin requires it if (getPluginsRequiringSA().includes(pluginObj.name)) { // We assume the user has enough permissions to do that. Fallback to a simple input maybe? - Kube.getServiceAccountNames(targetCluster, targetNamespace).then(saList => - setServiceAccountList(saList.serviceaccountNames), - ); + Kube.getServiceAccountNames(targetCluster, targetNamespace) + .then(saList => setServiceAccountList(saList.serviceaccountNames)) + ?.catch(e => { + dispatch(handleErrorAction(e)); + }); } return () => {}; }, [dispatch, targetCluster, targetNamespace, pluginObj.name]); diff --git a/dashboard/src/components/ErrorBoundary/ErrorBoundary.test.tsx b/dashboard/src/components/ErrorBoundary/ErrorBoundary.test.tsx index 3611e8d974d..9b01dc57558 100644 --- a/dashboard/src/components/ErrorBoundary/ErrorBoundary.test.tsx +++ b/dashboard/src/components/ErrorBoundary/ErrorBoundary.test.tsx @@ -1,7 +1,7 @@ // Copyright 2018-2022 the Kubeapps contributors. // SPDX-License-Identifier: Apache-2.0 -import { CdsInlineButton } from "@cds/react/button"; +import { CdsButton } from "@cds/react/button"; import Alert from "components/js/Alert"; import { mount } from "enzyme"; import React from "react"; @@ -93,7 +93,7 @@ it("logs out when clicking on the link", () => { <> , ); - const link = wrapper.find(Alert).find(CdsInlineButton); + const link = wrapper.find(Alert).find(CdsButton); expect(link).toExist(); (link.prop("onClick") as any)(); expect(logout).toHaveBeenCalled(); @@ -104,5 +104,5 @@ it("should not show the logout button if the error is catched from other compone wrapper.setState({ error: new Error("Boom!") }); const alert = wrapper.find(Alert); expect(alert).toExist(); - expect(alert.find(CdsInlineButton)).not.toExist(); + expect(alert.find(CdsButton)).not.toExist(); }); diff --git a/dashboard/src/components/ErrorBoundary/ErrorBoundary.tsx b/dashboard/src/components/ErrorBoundary/ErrorBoundary.tsx index 42bc1bc0ea7..6497a38fd40 100644 --- a/dashboard/src/components/ErrorBoundary/ErrorBoundary.tsx +++ b/dashboard/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -1,7 +1,7 @@ // Copyright 2018-2022 the Kubeapps contributors. // SPDX-License-Identifier: Apache-2.0 -import { CdsInlineButton } from "@cds/react/button"; +import { CdsButton } from "@cds/react/button"; import Alert from "components/js/Alert"; import React from "react"; @@ -33,10 +33,15 @@ class ErrorBoundary extends React.Component An error occurred: {err.message}.{" "} - {propsError && Logout} + {propsError && ( + + Log out + + )} ); } + return this.props.children; } diff --git a/dashboard/src/components/LoginForm/LoginForm.test.tsx b/dashboard/src/components/LoginForm/LoginForm.test.tsx index 4ca62ecfa34..25514c14fde 100644 --- a/dashboard/src/components/LoginForm/LoginForm.test.tsx +++ b/dashboard/src/components/LoginForm/LoginForm.test.tsx @@ -31,6 +31,7 @@ const defaultProps = { location: emptyLocation, checkCookieAuthentication: jest.fn().mockReturnValue({ then: jest.fn(f => f()), + catch: jest.fn(f => f()), }), oauthLoginURI: "", appVersion: "devel", @@ -215,6 +216,7 @@ describe("oauth login form", () => { ...props, checkCookieAuthentication: jest.fn().mockReturnValue({ then: jest.fn(() => false), + catch: jest.fn(() => false), }), }; const wrapper = mountWrapper( diff --git a/dashboard/src/containers/Root.test.tsx b/dashboard/src/containers/Root.test.tsx index a3dea5438f1..d6c23390a16 100644 --- a/dashboard/src/containers/Root.test.tsx +++ b/dashboard/src/containers/Root.test.tsx @@ -24,7 +24,9 @@ describe("118n configuration", () => { it("loads the async i18n config from getCustomI18nConfig", async () => { const config: II18nConfig = { locale: "custom", messages: { messageId: "translation" } }; - I18n.getCustomConfig = jest.fn().mockReturnValue({ then: jest.fn((f: any) => f(config)) }); + I18n.getCustomConfig = jest + .fn() + .mockReturnValue({ then: jest.fn((f: any) => f(config)), catch: jest.fn(f => f()) }); act(() => { const wrapper = shallow(); expect(wrapper.find(IntlProvider).prop("locale")).toBe("custom"); diff --git a/dashboard/src/shared/Auth.test.ts b/dashboard/src/shared/Auth.test.ts index 346678ed9d7..bce51067762 100644 --- a/dashboard/src/shared/Auth.test.ts +++ b/dashboard/src/shared/Auth.test.ts @@ -19,7 +19,7 @@ describe("Auth", () => { .fn() .mockImplementation(() => Promise.resolve({ exists: true } as CheckNamespaceExistsRequest)); jest.spyOn(client, "CheckNamespaceExists").mockImplementation(mockClientCheckNamespaceExists); - jest.spyOn(Auth, "resourcesClient").mockImplementation(() => client); + jest.spyOn(Auth, "resourcesServiceClient").mockImplementation(() => client); }); afterEach(() => { jest.resetAllMocks(); @@ -28,7 +28,7 @@ describe("Auth", () => { it("should return without error when the endpoint succeeds with the given token", async () => { await Auth.validateToken("othercluster", "foo"); - expect(Auth.resourcesClient).toHaveBeenCalledWith("foo"); + expect(Auth.resourcesServiceClient).toHaveBeenCalledWith("foo"); expect(mockClientCheckNamespaceExists).toHaveBeenCalledWith({ context: { cluster: "othercluster", @@ -44,7 +44,7 @@ describe("Auth", () => { jest.spyOn(client, "CheckNamespaceExists").mockImplementation(mockClientCheckNamespaceExists); await Auth.validateToken("othercluster", "foo"); - expect(Auth.resourcesClient).toHaveBeenCalledWith("foo"); + expect(Auth.resourcesServiceClient).toHaveBeenCalledWith("foo"); expect(mockClientCheckNamespaceExists).toHaveBeenCalledWith({ context: { cluster: "othercluster", @@ -89,7 +89,7 @@ describe("Auth", () => { it("returns true if request to API root succeeds", async () => { const isAuthed = await Auth.isAuthenticatedWithCookie("somecluster"); - expect(Auth.resourcesClient).toHaveBeenCalledWith(); + expect(Auth.resourcesServiceClient).toHaveBeenCalledWith(); expect(mockClientCheckNamespaceExists).toHaveBeenCalledWith({ context: { cluster: "somecluster", diff --git a/dashboard/src/shared/Auth.ts b/dashboard/src/shared/Auth.ts index 3fff20b2653..12293c888bf 100644 --- a/dashboard/src/shared/Auth.ts +++ b/dashboard/src/shared/Auth.ts @@ -1,17 +1,22 @@ // Copyright 2018-2022 the Kubeapps contributors. // SPDX-License-Identifier: Apache-2.0 +import { grpc } from "@improbable-eng/grpc-web"; import { AxiosResponse } from "axios"; import * as jwt from "jsonwebtoken"; import { get } from "lodash"; import { IConfig } from "./Config"; import { KubeappsGrpcClient } from "./KubeappsGrpcClient"; -import { grpc } from "@improbable-eng/grpc-web"; +import { + InternalServerNetworkError, + NotFoundNetworkError, + UnauthorizedNetworkError, +} from "./types"; const AuthTokenKey = "kubeapps_auth_token"; const AuthTokenOIDCKey = "kubeapps_auth_token_oidc"; export class Auth { - public static resourcesClient = (token?: string) => + public static resourcesServiceClient = (token?: string) => new KubeappsGrpcClient().getResourcesServiceClientImpl(token); public static getAuthToken() { @@ -68,12 +73,12 @@ export class Auth { // Throws an error if the token is invalid public static async validateToken(cluster: string, token: string) { try { - await this.resourcesClient(token).CheckNamespaceExists({ + await this.resourcesServiceClient(token).CheckNamespaceExists({ context: { cluster, namespace: "default" }, }); } catch (e: any) { if (e.code === grpc.Code.Unauthenticated) { - throw new Error("invalid token"); + throw new UnauthorizedNetworkError("invalid token"); } // https://kubernetes.io/docs/reference/access-authn-authz/authentication/#anonymous-requests // Since we are always passing a token here, A 403 authorization error @@ -83,12 +88,12 @@ export class Auth { // are attempted (though we may want to revisit this in the future). if (e.code !== grpc.Code.PermissionDenied) { if (e.code === grpc.Code.NotFound) { - throw new Error("not found"); + throw new NotFoundNetworkError("not found"); } if (e.code === grpc.Code.Internal) { - throw new Error("internal error"); + throw new InternalServerNetworkError("internal error"); } - throw new Error(`${e.code}: ${e.message}`); + throw new InternalServerNetworkError(`${e.code}: ${e.message}`); } } } @@ -146,7 +151,7 @@ export class Auth { // it could potentially return a false positive. public static async isAuthenticatedWithCookie(cluster: string): Promise { try { - await this.resourcesClient().CheckNamespaceExists({ + await this.resourcesServiceClient().CheckNamespaceExists({ context: { cluster, namespace: "default" }, }); } catch (e: any) { diff --git a/dashboard/src/shared/AxiosInstance.test.ts b/dashboard/src/shared/AxiosInstance.test.ts index 99f6ea64033..5845f1185b5 100644 --- a/dashboard/src/shared/AxiosInstance.test.ts +++ b/dashboard/src/shared/AxiosInstance.test.ts @@ -9,13 +9,13 @@ import { addAuthHeaders, addErrorHandling, axios } from "shared/AxiosInstance"; import { Auth } from "./Auth"; import { initialState } from "./specs/mountWrapper"; import { - ConflictError, - ForbiddenError, - InternalServerError, + ConflictNetworkError, + ForbiddenNetworkError, + InternalServerNetworkError, IStoreState, - NotFoundError, - UnauthorizedError, - UnprocessableEntity, + NotFoundNetworkError, + UnauthorizedNetworkError, + UnprocessableEntityError, } from "./types"; describe("createAxiosInterceptorWithAuth", () => { @@ -71,12 +71,12 @@ describe("createAxiosInterceptorWithAuth", () => { }); const testCases = [ - { code: 401, errorClass: UnauthorizedError }, - { code: 403, errorClass: ForbiddenError }, - { code: 404, errorClass: NotFoundError }, - { code: 409, errorClass: ConflictError }, - { code: 422, errorClass: UnprocessableEntity }, - { code: 500, errorClass: InternalServerError }, + { code: 401, errorClass: UnauthorizedNetworkError }, + { code: 403, errorClass: ForbiddenNetworkError }, + { code: 404, errorClass: NotFoundNetworkError }, + { code: 409, errorClass: ConflictNetworkError }, + { code: 422, errorClass: UnprocessableEntityError }, + { code: 500, errorClass: InternalServerNetworkError }, ]; testCases.forEach(t => { diff --git a/dashboard/src/shared/AxiosInstance.ts b/dashboard/src/shared/AxiosInstance.ts index 652a5d2a6f7..5450d607b22 100644 --- a/dashboard/src/shared/AxiosInstance.ts +++ b/dashboard/src/shared/AxiosInstance.ts @@ -7,14 +7,14 @@ import { ThunkDispatch } from "redux-thunk"; import actions from "../actions"; import { Auth } from "./Auth"; import { - ConflictError, - ForbiddenError, - InternalServerError, + ConflictNetworkError, + ForbiddenNetworkError, + InternalServerNetworkError, IRBACRole, IStoreState, - NotFoundError, - UnauthorizedError, - UnprocessableEntity, + NotFoundNetworkError, + UnauthorizedNetworkError, + UnprocessableEntityError, } from "./types"; export function addAuthHeaders(axiosInstance: AxiosInstance) { @@ -63,7 +63,7 @@ export function addErrorHandling(axiosInstance: AxiosInstance, store: Store { const { apiGroup, resource, namespace, clusterWide, verbs } = forbiddenAction; @@ -99,15 +99,15 @@ export function addErrorHandling(axiosInstance: AxiosInstance, store: Store) { // packages service implementation. const mockClient = new KubeappsGrpcClient().getPackagesServiceClientImpl(); jest.spyOn(mockClient, fnToMock).mockImplementation(mockFn); - jest.spyOn(InstalledPackage, "coreClient").mockImplementation(() => mockClient); + jest.spyOn(InstalledPackage, "packagesServiceClient").mockImplementation(() => mockClient); } function setMockHelmClient(fnToMock: any, mockFn: jest.Mock) { @@ -213,5 +213,5 @@ function setMockHelmClient(fnToMock: any, mockFn: jest.Mock) { // helm packages service implementation. const mockClient = new KubeappsGrpcClient().getHelmPackagesServiceClientImpl(); jest.spyOn(mockClient, fnToMock).mockImplementation(mockFn); - jest.spyOn(InstalledPackage, "helmPluginClient").mockImplementation(() => mockClient); + jest.spyOn(InstalledPackage, "helmPackagesServiceClient").mockImplementation(() => mockClient); } diff --git a/dashboard/src/shared/InstalledPackage.ts b/dashboard/src/shared/InstalledPackage.ts index d450ac29ff0..953b0c3bd93 100644 --- a/dashboard/src/shared/InstalledPackage.ts +++ b/dashboard/src/shared/InstalledPackage.ts @@ -16,11 +16,12 @@ import { RollbackInstalledPackageResponse, } from "gen/kubeappsapis/plugins/helm/packages/v1alpha1/helm"; import { KubeappsGrpcClient } from "./KubeappsGrpcClient"; -import { getPluginsSupportingRollback } from "./utils"; +import { convertGrpcAuthError, getPluginsSupportingRollback } from "./utils"; export class InstalledPackage { - public static coreClient = () => new KubeappsGrpcClient().getPackagesServiceClientImpl(); - public static helmPluginClient = () => + public static packagesServiceClient = () => + new KubeappsGrpcClient().getPackagesServiceClientImpl(); + public static helmPackagesServiceClient = () => new KubeappsGrpcClient().getHelmPackagesServiceClientImpl(); public static async GetInstalledPackageSummaries( @@ -29,22 +30,34 @@ export class InstalledPackage { pageToken?: string, size?: number, ) { - return await this.coreClient().GetInstalledPackageSummaries({ - context: { cluster: cluster, namespace: namespace }, - paginationOptions: { pageSize: size || 0, pageToken: pageToken || "" }, - }); + return await this.packagesServiceClient() + .GetInstalledPackageSummaries({ + context: { cluster: cluster, namespace: namespace }, + paginationOptions: { pageSize: size || 0, pageToken: pageToken || "" }, + }) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); } public static async GetInstalledPackageDetail(installedPackageRef?: InstalledPackageReference) { - return await this.coreClient().GetInstalledPackageDetail({ - installedPackageRef: installedPackageRef, - }); + return await this.packagesServiceClient() + .GetInstalledPackageDetail({ + installedPackageRef: installedPackageRef, + }) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); } public static async GetInstalledPackageResourceRefs( installedPackageRef?: InstalledPackageReference, ) { - return await this.coreClient().GetInstalledPackageResourceRefs({ installedPackageRef }); + return await this.packagesServiceClient() + .GetInstalledPackageResourceRefs({ installedPackageRef }) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); } public static async CreateInstalledPackage( @@ -55,14 +68,18 @@ export class InstalledPackage { values?: string, reconciliationOptions?: ReconciliationOptions, ) { - return await this.coreClient().CreateInstalledPackage({ - name, - values, - targetContext, - availablePackageRef, - pkgVersionReference, - reconciliationOptions, - } as CreateInstalledPackageRequest); + return await this.packagesServiceClient() + .CreateInstalledPackage({ + name, + values, + targetContext, + availablePackageRef, + pkgVersionReference, + reconciliationOptions, + } as CreateInstalledPackageRequest) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); } public static async UpdateInstalledPackage( @@ -71,12 +88,16 @@ export class InstalledPackage { values?: string, reconciliationOptions?: ReconciliationOptions, ) { - return await this.coreClient().UpdateInstalledPackage({ - installedPackageRef, - pkgVersionReference, - values, - reconciliationOptions, - } as UpdateInstalledPackageRequest); + return await this.packagesServiceClient() + .UpdateInstalledPackage({ + installedPackageRef, + pkgVersionReference, + values, + reconciliationOptions, + } as UpdateInstalledPackageRequest) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); } public static async RollbackInstalledPackage( @@ -88,18 +109,26 @@ export class InstalledPackage { installedPackageRef?.plugin?.name && getPluginsSupportingRollback().includes(installedPackageRef.plugin.name) ) { - return await this.helmPluginClient().RollbackInstalledPackage({ - installedPackageRef, - releaseRevision, - } as RollbackInstalledPackageRequest); + return await this.helmPackagesServiceClient() + .RollbackInstalledPackage({ + installedPackageRef, + releaseRevision, + } as RollbackInstalledPackageRequest) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); } else { return {} as RollbackInstalledPackageResponse; } } public static async DeleteInstalledPackage(installedPackageRef: InstalledPackageReference) { - return await this.coreClient().DeleteInstalledPackage({ - installedPackageRef, - } as DeleteInstalledPackageRequest); + return await this.packagesServiceClient() + .DeleteInstalledPackage({ + installedPackageRef, + } as DeleteInstalledPackageRequest) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); } } diff --git a/dashboard/src/shared/Kube.test.ts b/dashboard/src/shared/Kube.test.ts index a44a7f36f6b..bbef95e3d0c 100644 --- a/dashboard/src/shared/Kube.test.ts +++ b/dashboard/src/shared/Kube.test.ts @@ -168,12 +168,12 @@ describe("App", () => { .fn() .mockImplementation(() => Promise.resolve({ allowed: true } as CanIResponse)); jest.spyOn(client, "CanI").mockImplementation(mockClientCanI); - jest.spyOn(Kube, "resourcesClient").mockImplementation(() => client); + jest.spyOn(Kube, "resourcesServiceClient").mockImplementation(() => client); const allowed = await Kube.canI("cluster", "v1", "namespaces", "create", ""); expect(allowed).toBe(true); - expect(Kube.resourcesClient).toHaveBeenCalledWith(); + expect(Kube.resourcesServiceClient).toHaveBeenCalledWith(); expect(mockClientCanI).toHaveBeenCalledWith({ context: { cluster: "cluster", @@ -187,7 +187,7 @@ describe("App", () => { it("should ignore empty clusters", async () => { const allowed = await Kube.canI("", "v1", "namespaces", "create", ""); expect(allowed).toBe(false); - expect(Kube.resourcesClient).not.toHaveBeenCalled(); + expect(Kube.resourcesServiceClient).not.toHaveBeenCalled(); }); it("should default to disallow when errors", async () => { mockClientCanI = jest.fn().mockImplementation( @@ -197,12 +197,12 @@ describe("App", () => { }), ); jest.spyOn(client, "CanI").mockImplementation(mockClientCanI); - jest.spyOn(Kube, "resourcesClient").mockImplementation(() => client); + jest.spyOn(Kube, "resourcesServiceClient").mockImplementation(() => client); const allowed = await Kube.canI("cluster", "v1", "secrets", "list", ""); expect(allowed).toBe(false); - expect(Kube.resourcesClient).toHaveBeenCalled(); + expect(Kube.resourcesServiceClient).toHaveBeenCalled(); expect(mockClientCanI).toHaveBeenCalledWith({ context: { cluster: "cluster", diff --git a/dashboard/src/shared/Kube.ts b/dashboard/src/shared/Kube.ts index 0c4c4bb1899..00987cae4c0 100644 --- a/dashboard/src/shared/Kube.ts +++ b/dashboard/src/shared/Kube.ts @@ -13,12 +13,14 @@ import * as url from "shared/url"; import { axiosWithAuth } from "./AxiosInstance"; import { KubeappsGrpcClient } from "./KubeappsGrpcClient"; import { IKubeState } from "./types"; +import { convertGrpcAuthError } from "./utils"; // Kube is a lower-level class for interacting with the Kubernetes API. Use // ResourceRef to interact with a single API resource rather than using Kube // directly. export class Kube { - public static resourcesClient = () => new KubeappsGrpcClient().getResourcesServiceClientImpl(); + public static resourcesServiceClient = () => + new KubeappsGrpcClient().getResourcesServiceClientImpl(); // getResources returns a subscription to an observable for resources from the server. public static getResources( @@ -26,7 +28,7 @@ export class Kube { refs: ResourceRef[], watch: boolean, ) { - return this.resourcesClient().GetResources({ + return this.resourcesServiceClient().GetResources({ installedPackageRef: pkgRef, resourceRefs: refs, watch, @@ -80,7 +82,7 @@ export class Kube { return false; } // TODO(rcastelblanq) Migrate the CanI endpoint to a proper RBAC/Auth plugin - const response = await this.resourcesClient().CanI({ + const response = await this.resourcesServiceClient().CanI({ context: { cluster, namespace }, group: group, resource: resource, @@ -96,8 +98,12 @@ export class Kube { cluster: string, namespace: string, ): Promise { - return await this.resourcesClient().GetServiceAccountNames({ - context: { cluster, namespace }, - } as GetServiceAccountNamesRequest); + return await this.resourcesServiceClient() + .GetServiceAccountNames({ + context: { cluster, namespace }, + } as GetServiceAccountNamesRequest) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); } } diff --git a/dashboard/src/shared/Namespace.ts b/dashboard/src/shared/Namespace.ts index 939083663fa..26bda1b2aab 100644 --- a/dashboard/src/shared/Namespace.ts +++ b/dashboard/src/shared/Namespace.ts @@ -3,14 +3,20 @@ import { get } from "lodash"; import { Auth } from "./Auth"; -import { ForbiddenError, NotFoundError } from "./types"; import { KubeappsGrpcClient } from "./KubeappsGrpcClient"; +import { convertGrpcAuthError } from "./utils"; export default class Namespace { - private static resourcesClient = () => new KubeappsGrpcClient().getResourcesServiceClientImpl(); + private static resourcesServiceClient = () => + new KubeappsGrpcClient().getResourcesServiceClientImpl(); public static async list(cluster: string) { - return this.resourcesClient().GetNamespaceNames({ cluster: cluster }); + const { namespaceNames } = await this.resourcesServiceClient() + .GetNamespaceNames({ cluster: cluster }) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); + return namespaceNames; } public static async create( @@ -18,37 +24,31 @@ export default class Namespace { namespace: string, labels: { [key: string]: string }, ) { - await this.resourcesClient().CreateNamespace({ - context: { - cluster, - namespace, - }, - labels: labels, - }); + await this.resourcesServiceClient() + .CreateNamespace({ + context: { + cluster, + namespace, + }, + labels: labels, + }) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); } public static async exists(cluster: string, namespace: string) { - try { - const { exists } = await this.resourcesClient().CheckNamespaceExists({ + const { exists } = await this.resourcesServiceClient() + .CheckNamespaceExists({ context: { cluster, namespace, }, + }) + .catch((e: any) => { + throw convertGrpcAuthError(e); }); - - return exists; - } catch (e: any) { - switch (e.constructor) { - case ForbiddenError: - throw new ForbiddenError( - `You don't have sufficient permissions to use the namespace ${namespace}`, - ); - case NotFoundError: - throw new NotFoundError(`Namespace ${namespace} not found. Create it before using it.`); - default: - throw e; - } - } + return exists; } } diff --git a/dashboard/src/shared/PackageRepositoriesService.ts b/dashboard/src/shared/PackageRepositoriesService.ts index 319c346cddb..f5729f8f581 100644 --- a/dashboard/src/shared/PackageRepositoriesService.ts +++ b/dashboard/src/shared/PackageRepositoriesService.ts @@ -30,6 +30,7 @@ import { } from "gen/kubeappsapis/plugins/kapp_controller/packages/v1alpha1/kapp_controller"; import KubeappsGrpcClient from "./KubeappsGrpcClient"; import { IPkgRepoFormData, PluginNames } from "./types"; +import { convertGrpcAuthError } from "./utils"; export class PackageRepositoriesService { public static coreRepositoriesClient = () => @@ -40,13 +41,21 @@ export class PackageRepositoriesService { public static async getPackageRepositorySummaries( context: Context, ): Promise { - return await this.coreRepositoriesClient().GetPackageRepositorySummaries({ context }); + return await this.coreRepositoriesClient() + .GetPackageRepositorySummaries({ context }) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); } public static async getPackageRepositoryDetail( packageRepoRef: PackageRepositoryReference, ): Promise { - return await this.coreRepositoriesClient().GetPackageRepositoryDetail({ packageRepoRef }); + return await this.coreRepositoriesClient() + .GetPackageRepositoryDetail({ packageRepoRef }) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); } public static async addPackageRepository(cluster: string, request: IPkgRepoFormData) { @@ -57,7 +66,11 @@ export class PackageRepositoriesService { PackageRepositoriesService.buildEncodedCustomDetail(request), ); - return await this.coreRepositoriesClient().AddPackageRepository(addPackageRepositoryRequest); + return await this.coreRepositoriesClient() + .AddPackageRepository(addPackageRepositoryRequest) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); } public static async updatePackageRepository(cluster: string, request: IPkgRepoFormData) { @@ -68,17 +81,23 @@ export class PackageRepositoriesService { PackageRepositoriesService.buildEncodedCustomDetail(request), ); - return await this.coreRepositoriesClient().UpdatePackageRepository( - updatePackageRepositoryRequest, - ); + return await this.coreRepositoriesClient() + .UpdatePackageRepository(updatePackageRepositoryRequest) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); } public static async deletePackageRepository( packageRepoRef: PackageRepositoryReference, ): Promise { - return await this.coreRepositoriesClient().DeletePackageRepository({ - packageRepoRef, - }); + return await this.coreRepositoriesClient() + .DeletePackageRepository({ + packageRepoRef, + }) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); } private static buildAddOrUpdateRequest( @@ -246,6 +265,10 @@ export class PackageRepositoriesService { } public static async getConfiguredPlugins(): Promise { - return await this.pluginsServiceClientImpl().GetConfiguredPlugins({}); + return await this.pluginsServiceClientImpl() + .GetConfiguredPlugins({}) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); } } diff --git a/dashboard/src/shared/PackagesService.ts b/dashboard/src/shared/PackagesService.ts index 6d54f055501..bbdf5e6dab4 100644 --- a/dashboard/src/shared/PackagesService.ts +++ b/dashboard/src/shared/PackagesService.ts @@ -7,8 +7,8 @@ import { GetAvailablePackageSummariesResponse, GetAvailablePackageVersionsResponse, } from "gen/kubeappsapis/core/packages/v1alpha1/packages"; -import { GetConfiguredPluginsResponse } from "gen/kubeappsapis/core/plugins/v1alpha1/plugins"; import { KubeappsGrpcClient } from "./KubeappsGrpcClient"; +import { convertGrpcAuthError } from "./utils"; export default class PackagesService { public static packagesServiceClient = () => @@ -24,35 +24,43 @@ export default class PackagesService { size: number, query?: string, ): Promise { - return await this.packagesServiceClient().GetAvailablePackageSummaries({ - context: { cluster: cluster, namespace: namespace }, - filterOptions: { - query: query, - repositories: repos ? repos.split(",") : [], - }, - paginationOptions: { pageSize: size, pageToken: paginationToken }, - }); + return await this.packagesServiceClient() + .GetAvailablePackageSummaries({ + context: { cluster: cluster, namespace: namespace }, + filterOptions: { + query: query, + repositories: repos ? repos.split(",") : [], + }, + paginationOptions: { pageSize: size, pageToken: paginationToken }, + }) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); } public static async getAvailablePackageVersions( availablePackageReference?: AvailablePackageReference, ): Promise { - return await this.packagesServiceClient().GetAvailablePackageVersions({ - availablePackageRef: availablePackageReference, - }); + return await this.packagesServiceClient() + .GetAvailablePackageVersions({ + availablePackageRef: availablePackageReference, + }) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); } public static async getAvailablePackageDetail( availablePackageReference?: AvailablePackageReference, version?: string, ): Promise { - return await this.packagesServiceClient().GetAvailablePackageDetail({ - pkgVersion: version, - availablePackageRef: availablePackageReference, - }); - } - - public static async getConfiguredPlugins(): Promise { - return await this.pluginsServiceClientImpl().GetConfiguredPlugins({}); + return await this.packagesServiceClient() + .GetAvailablePackageDetail({ + pkgVersion: version, + availablePackageRef: availablePackageReference, + }) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); } } diff --git a/dashboard/src/shared/Secret.test.ts b/dashboard/src/shared/Secret.test.ts index e1909b7ec2f..eac234b6359 100644 --- a/dashboard/src/shared/Secret.test.ts +++ b/dashboard/src/shared/Secret.test.ts @@ -1,7 +1,6 @@ // Copyright 2020-2022 the Kubeapps contributors. // SPDX-License-Identifier: Apache-2.0 -import Secret from "./Secret"; import { CreateSecretRequest, CreateSecretResponse, @@ -9,6 +8,7 @@ import { SecretType, } from "gen/kubeappsapis/plugins/resources/v1alpha1/resources"; import { KubeappsGrpcClient } from "./KubeappsGrpcClient"; +import Secret from "./Secret"; describe("getSecretNames", () => { const expectedSecretNames = { @@ -27,7 +27,7 @@ describe("getSecretNames", () => { ); jest.spyOn(client, "GetSecretNames").mockImplementation(mockClientGetSecretNames); - jest.spyOn(Secret, "resourcesClient").mockImplementation(() => client); + jest.spyOn(Secret, "resourcesServiceClient").mockImplementation(() => client); }); afterEach(() => { jest.restoreAllMocks(); @@ -50,7 +50,7 @@ describe("createSecret", () => { .mockImplementation(() => Promise.resolve({} as CreateSecretResponse)); jest.spyOn(client, "CreateSecret").mockImplementation(mockClientCreateSecret); - jest.spyOn(Secret, "resourcesClient").mockImplementation(() => client); + jest.spyOn(Secret, "resourcesServiceClient").mockImplementation(() => client); }); afterEach(() => { jest.restoreAllMocks(); diff --git a/dashboard/src/shared/Secret.ts b/dashboard/src/shared/Secret.ts index caac56b581c..388645701e7 100644 --- a/dashboard/src/shared/Secret.ts +++ b/dashboard/src/shared/Secret.ts @@ -6,17 +6,24 @@ import { SecretType, } from "gen/kubeappsapis/plugins/resources/v1alpha1/resources"; import { KubeappsGrpcClient } from "./KubeappsGrpcClient"; +import { convertGrpcAuthError } from "./utils"; export default class Secret { - public static resourcesClient = () => new KubeappsGrpcClient().getResourcesServiceClientImpl(); + public static resourcesServiceClient = () => + new KubeappsGrpcClient().getResourcesServiceClientImpl(); + // TODO(agamez): unused method, remove? public static async getDockerConfigSecretNames(cluster: string, namespace: string) { - const result = await this.resourcesClient().GetSecretNames({ - context: { - cluster, - namespace, - }, - }); + const result = await this.resourcesServiceClient() + .GetSecretNames({ + context: { + cluster, + namespace, + }, + }) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); const secretNames = []; for (const [name, type] of Object.entries(result.secretNames)) { @@ -27,6 +34,7 @@ export default class Secret { return secretNames; } + // TODO(agamez): unused method, remove? public static async createPullSecret( cluster: string, name: string, @@ -46,16 +54,20 @@ export default class Secret { }, }, }; - await this.resourcesClient().CreateSecret({ - context: { - cluster, - namespace, - }, - name, - type: SecretType.SECRET_TYPE_DOCKER_CONFIG_JSON, - stringData: { - ".dockerconfigjson": JSON.stringify(dockercfg), - }, - } as CreateSecretRequest); + await this.resourcesServiceClient() + .CreateSecret({ + context: { + cluster, + namespace, + }, + name, + type: SecretType.SECRET_TYPE_DOCKER_CONFIG_JSON, + stringData: { + ".dockerconfigjson": JSON.stringify(dockercfg), + }, + } as CreateSecretRequest) + .catch((e: any) => { + throw convertGrpcAuthError(e); + }); } } diff --git a/dashboard/src/shared/types.ts b/dashboard/src/shared/types.ts index 491b7775d8f..1b0eec27e24 100644 --- a/dashboard/src/shared/types.ts +++ b/dashboard/src/shared/types.ts @@ -54,18 +54,37 @@ export class CustomError extends Error { } } -export class ForbiddenError extends CustomError {} - -export class UnauthorizedError extends CustomError {} - -export class NotFoundError extends CustomError {} - -export class ConflictError extends CustomError {} - -export class UnprocessableEntity extends CustomError {} - -export class InternalServerError extends CustomError {} - +// For 4XX HTTP-alike errors +export class ClientNetworkError extends CustomError {} +// 400 +export class BadRequestNetworkError extends ClientNetworkError {} +// 401 +export class UnauthorizedNetworkError extends ClientNetworkError {} +// 403 +export class ForbiddenNetworkError extends ClientNetworkError {} +// 404 +export class NotFoundNetworkError extends ClientNetworkError {} +// 408 +export class RequestTimeoutNetworkError extends ClientNetworkError {} +// 409 +export class ConflictNetworkError extends ClientNetworkError {} +// 422 +export class UnprocessableEntityError extends ClientNetworkError {} +// 429 +export class TooManyRequestsNetworkError extends ClientNetworkError {} + +// For 5XX HTTP-alike errors +export class ServerNetworkError extends CustomError {} +// 500 +export class InternalServerNetworkError extends ServerNetworkError {} +// 501 +export class NotImplementedNetworkError extends ServerNetworkError {} +// 503 +export class ServerUnavailableNetworkError extends ServerNetworkError {} +// 504 +export class GatewayTimeoutNetworkError extends ServerNetworkError {} + +// Application-level errors export class FetchError extends CustomError {} export class FetchWarning extends CustomError {} diff --git a/dashboard/src/shared/utils.ts b/dashboard/src/shared/utils.ts index cd4a7f8418b..6dad10d2e30 100644 --- a/dashboard/src/shared/utils.ts +++ b/dashboard/src/shared/utils.ts @@ -1,6 +1,7 @@ // Copyright 2018-2022 the Kubeapps contributors. // SPDX-License-Identifier: Apache-2.0 +import { grpc } from "@improbable-eng/grpc-web"; import { InstalledPackageStatus_StatusReason, installedPackageStatus_StatusReasonToJSON, @@ -13,7 +14,22 @@ import helmIcon from "icons/helm.svg"; import olmIcon from "icons/olm-icon.svg"; import placeholder from "icons/placeholder.svg"; import { IConfig } from "./Config"; -import { PluginNames, RepositoryStorageTypes } from "./types"; +import { + BadRequestNetworkError, + ConflictNetworkError, + CustomError, + ForbiddenNetworkError, + GatewayTimeoutNetworkError, + InternalServerNetworkError, + NotFoundNetworkError, + NotImplementedNetworkError, + PluginNames, + RepositoryStorageTypes, + RequestTimeoutNetworkError, + ServerUnavailableNetworkError, + TooManyRequestsNetworkError, + UnauthorizedNetworkError, +} from "./types"; export const k8sObjectNameRegex = "[a-z0-9]([-a-z0-9]*[a-z0-9])?(.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*"; @@ -276,3 +292,61 @@ export function getGlobalNamespaceOrNamespace( return "unknown"; } } + +// Using the mapping from GRPC and grpc-gateway +// See https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto +// See https://github.com/grpc-ecosystem/grpc-gateway/blob/master/runtime/errors.go +export function convertGrpcAuthError(e: any): CustomError | any { + const msg = e?.metadata?.headersMap?.["grpc-message"].toString(); + switch (e?.code) { + case grpc.Code.Unauthenticated: + return new UnauthorizedNetworkError(msg); + case grpc.Code.FailedPrecondition: + // Use `FAILED_PRECONDITION` if the client should not retry until the system state has been explicitly fixed. + //TODO(agamez): this code shouldn't be returned by the API, but it is + if (["credentials", "unauthorized"].some(p => msg?.toLowerCase()?.includes(p))) { + return new UnauthorizedNetworkError(msg); + } else { + return new BadRequestNetworkError(msg); + } + case grpc.Code.Internal: + //TODO(agamez): this code shouldn't be returned by the API, but it is + if (["credentials", "unauthorized"].some(p => msg?.toLowerCase()?.includes(p))) { + return new UnauthorizedNetworkError(msg); + } else { + return new InternalServerNetworkError(msg); + } + case grpc.Code.PermissionDenied: + return new ForbiddenNetworkError(msg); + case grpc.Code.NotFound: + return new NotFoundNetworkError(msg); + case grpc.Code.AlreadyExists: + return new ConflictNetworkError(msg); + case grpc.Code.InvalidArgument: + return new BadRequestNetworkError(msg); + case grpc.Code.DeadlineExceeded: + return new GatewayTimeoutNetworkError(msg); + case grpc.Code.ResourceExhausted: + return new TooManyRequestsNetworkError(msg); + case grpc.Code.Aborted: + // Use `ABORTED` if the client should retry at a higher level + return new ConflictNetworkError(msg); + case grpc.Code.Unimplemented: + return new NotImplementedNetworkError(msg); + case grpc.Code.OutOfRange: + return new BadRequestNetworkError(msg); + case grpc.Code.Unavailable: + // Use `UNAVAILABLE` if the client can retry just the failing call. + return new ServerUnavailableNetworkError(msg); + case grpc.Code.DataLoss: + return new InternalServerNetworkError(msg); + case grpc.Code.Unknown: + return new InternalServerNetworkError(msg); + case grpc.Code.Canceled: + return new RequestTimeoutNetworkError(msg); + case grpc.Code.OK: + return undefined; + default: + return e; + } +}