From f4d5d548708a3219b21df2882b7f13180bfc8181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20G=C3=A1mez=2C=20PhD?= Date: Fri, 8 Jul 2022 17:45:36 +0200 Subject: [PATCH] Switch to the new Repos API (#4954) * wip Revamp AppRepoForm Signed-off-by: Antonio Gamez Diaz * Update tests Signed-off-by: Antonio Gamez Diaz * Allow no-commented-out-tests temporarily Signed-off-by: Antonio Gamez Diaz * Fix linter issue Signed-off-by: Antonio Gamez Diaz * Fix linter issues Signed-off-by: Antonio Gamez Diaz * Delete AppRepository Signed-off-by: Antonio Gamez Diaz * Fix repos action Signed-off-by: Antonio Gamez Diaz * Add remaining changes Signed-off-by: Antonio Gamez Diaz * wip fix some test cases Signed-off-by: Antonio Gamez Diaz * Fix repos.ts test suite Signed-off-by: Antonio Gamez Diaz * Implement PackageRepositoriesService test suite Signed-off-by: Antonio Gamez Diaz * Add plural names. Add packageFormat in repo list Signed-off-by: Antonio Gamez Diaz * Add AppRepoForm Signed-off-by: Antonio Gamez Diaz * Fix AppRepoForm test cases Signed-off-by: Antonio Gamez Diaz * Fix remaining tests Signed-off-by: Antonio Gamez Diaz * Remove no longer used urls Signed-off-by: Antonio Gamez Diaz * Avoid setting auth values if undefined Signed-off-by: Antonio Gamez Diaz * Fix e2e tests Signed-off-by: Antonio Gamez Diaz * Allow setting pullSecret in the UI. Improve CA input/secret selector Signed-off-by: Antonio Gamez Diaz * Disable e2e test 03 Signed-off-by: Antonio Gamez Diaz * Fix linter issue Signed-off-by: Antonio Gamez Diaz * Fix test case Signed-off-by: Antonio Gamez Diaz * Fix test 03 Signed-off-by: Antonio Gamez Diaz * Run prettier Signed-off-by: Antonio Gamez Diaz * Minor file renames from AppRepo to PkgRepo (#4955) * Minor renames Signed-off-by: Antonio Gamez Diaz * Rename files from AppRepo to PkgRepo Signed-off-by: Antonio Gamez Diaz * Fix test cases Signed-off-by: Antonio Gamez Diaz * Comment-out e2e test Signed-off-by: Antonio Gamez Diaz * Extract regex to a const Signed-off-by: Antonio Gamez Diaz * Add some suggestions from code review Signed-off-by: Antonio Gamez Diaz * Fix type issues in actons. Perform renames Signed-off-by: Antonio Gamez Diaz * Rename kubeappsNamespace to globalReposNamespace Signed-off-by: Antonio Gamez Diaz * Remove validation action. Fix action tests Signed-off-by: Antonio Gamez Diaz * Return summaries in action to avoid full reload Signed-off-by: Antonio Gamez Diaz * Add test case for sorting when adding repo Signed-off-by: Antonio Gamez Diaz * Disable unsupported auth methods Signed-off-by: Antonio Gamez Diaz * Suggest pull secrets if usign docker creds Signed-off-by: Antonio Gamez Diaz * Remove ad-hoc helm client Signed-off-by: Antonio Gamez Diaz * Extract customDetails encoding to a fn Signed-off-by: Antonio Gamez Diaz * Delete leftover Signed-off-by: Antonio Gamez Diaz * Remove apprepo management from Kubeops (#5026) * Remove unused logic from kubeops Signed-off-by: Antonio Gamez Diaz * Remove old kubeops endpoint from api docs Signed-off-by: Antonio Gamez Diaz * Remove unused kubeops args Signed-off-by: Antonio Gamez Diaz * Address PR's comments Signed-off-by: Antonio Gamez Diaz * Tidy up the `/pkg` folder (#5027) * Remove pkg/handlerutil Signed-off-by: Antonio Gamez Diaz * Remove unused code from pkg/agent Signed-off-by: Antonio Gamez Diaz * Move pkg/agent to plugins/pkg/agent Signed-off-by: Antonio Gamez Diaz * Add dockerjson auth type as supported to flux plg Signed-off-by: Antonio Gamez Diaz * cast request.customDetails to RepositoryCustomDetails Signed-off-by: Antonio Gamez Diaz * Avoid seding mixed user/kubeapps mgn secrets Signed-off-by: Antonio Gamez Diaz * Modify toggle msgs Signed-off-by: Antonio Gamez Diaz * Improve secret info msg Signed-off-by: Antonio Gamez Diaz --- .../templates/kubeops/deployment.yaml | 1 - .../plugins/helm/packages/v1alpha1/server.go | 5 +- .../helm/packages/v1alpha1/server_test.go | 2 +- .../helm/packages/v1alpha1/utils/utils.go | 17 + .../kubeapps-apis/plugins/pkg}/agent/agent.go | 73 - .../plugins/pkg}/agent/agent_test.go | 350 +--- .../pkg}/agent/docker_secrets_postrenderer.go | 0 .../agent/docker_secrets_postrenderer_test.go | 0 .../pkg}/agent/httpConfigForCluster.go | 0 .../plugins/pkg/clientgetter/clientgetter.go | 2 +- cmd/kubeops/cmd/root.go | 28 +- cmd/kubeops/cmd/root_test.go | 42 - cmd/kubeops/internal/auth/auth.go | 79 - cmd/kubeops/internal/auth/auth_test.go | 63 - cmd/kubeops/internal/auth/authgate.go | 20 - cmd/kubeops/internal/handler/handler.go | 319 --- cmd/kubeops/internal/handler/handler_test.go | 540 ----- .../internal/httphandler/http-handler.go | 269 +-- .../internal/httphandler/http-handler_test.go | 339 +--- cmd/kubeops/internal/response/response.go | 72 - .../internal/response/response_test.go | 97 - cmd/kubeops/server/server.go | 40 - dashboard/public/openapi.yaml | 664 +----- dashboard/src/actions/repos.test.tsx | 503 +++-- dashboard/src/actions/repos.ts | 275 ++- .../components/AppUpgrade/AppUpgrade.test.tsx | 54 +- dashboard/src/components/AppView/AppView.tsx | 89 +- .../src/components/Catalog/Catalog.test.tsx | 26 +- dashboard/src/components/Catalog/Catalog.tsx | 16 +- .../Config/AppRepoList/AppRepoForm.test.tsx | 699 ------- .../Config/AppRepoList/AppRepoForm.tsx | 948 --------- .../PkgRepoButton.test.tsx} | 40 +- .../PkgRepoButton.tsx} | 21 +- .../PkgRepoControl.scss} | 2 +- .../PkgRepoControl.test.tsx} | 22 +- .../PkgRepoControl.tsx} | 44 +- .../PkgRepoDisabledControl.tsx} | 6 +- .../PkgRepoForm.scss} | 0 .../Config/PkgRepoList/PkgRepoForm.test.tsx | 800 ++++++++ .../Config/PkgRepoList/PkgRepoForm.tsx | 1780 +++++++++++++++++ .../PkgRepoList.scss} | 0 .../PkgRepoList.test.tsx} | 82 +- .../PkgRepoList.tsx} | 85 +- .../{AppRepoList => PkgRepoList}/index.tsx | 4 +- .../DeploymentForm/DeploymentForm.tsx | 4 +- dashboard/src/components/Header/Menu.test.tsx | 8 +- dashboard/src/components/Header/Menu.tsx | 2 +- .../SelectRepoForm/SelectRepoForm.test.tsx | 48 +- .../SelectRepoForm/SelectRepoForm.tsx | 38 +- .../src/containers/RoutesContainer/Routes.tsx | 11 +- dashboard/src/reducers/repos.test.ts | 109 +- dashboard/src/reducers/repos.ts | 81 +- dashboard/src/shared/AppRepository.test.ts | 135 -- dashboard/src/shared/AppRepository.ts | 110 - .../shared/PackageRepositoriesService.test.ts | 236 +++ .../src/shared/PackageRepositoriesService.ts | 230 +++ dashboard/src/shared/types.ts | 76 +- dashboard/src/shared/url.ts | 20 +- dashboard/src/shared/utils.test.ts | 21 + dashboard/src/shared/utils.ts | 54 +- .../main/02-create-package-repository.spec.js | 20 +- ...-create-private-package-repository.spec.js | 174 +- .../09-user-positive-installation.spec.js | 11 +- ...-deploy-package-with-private-image.spec.js | 121 +- pkg/handlerutil/handlerutil.go | 106 - pkg/handlerutil/handlerutil_test.go | 33 - 66 files changed, 4292 insertions(+), 5874 deletions(-) rename {pkg => cmd/kubeapps-apis/plugins/pkg}/agent/agent.go (75%) rename {pkg => cmd/kubeapps-apis/plugins/pkg}/agent/agent_test.go (54%) rename {pkg => cmd/kubeapps-apis/plugins/pkg}/agent/docker_secrets_postrenderer.go (100%) rename {pkg => cmd/kubeapps-apis/plugins/pkg}/agent/docker_secrets_postrenderer_test.go (100%) rename {pkg => cmd/kubeapps-apis/plugins/pkg}/agent/httpConfigForCluster.go (100%) delete mode 100644 cmd/kubeops/internal/auth/auth.go delete mode 100644 cmd/kubeops/internal/auth/auth_test.go delete mode 100644 cmd/kubeops/internal/auth/authgate.go delete mode 100644 cmd/kubeops/internal/handler/handler.go delete mode 100644 cmd/kubeops/internal/handler/handler_test.go delete mode 100644 cmd/kubeops/internal/response/response.go delete mode 100644 cmd/kubeops/internal/response/response_test.go delete mode 100644 dashboard/src/components/Config/AppRepoList/AppRepoForm.test.tsx delete mode 100644 dashboard/src/components/Config/AppRepoList/AppRepoForm.tsx rename dashboard/src/components/Config/{AppRepoList/AppRepoButton.test.tsx => PkgRepoList/PkgRepoButton.test.tsx} (72%) rename dashboard/src/components/Config/{AppRepoList/AppRepoButton.tsx => PkgRepoList/PkgRepoButton.tsx} (79%) rename dashboard/src/components/Config/{AppRepoList/AppRepoControl.scss => PkgRepoList/PkgRepoControl.scss} (83%) rename dashboard/src/components/Config/{AppRepoList/AppRepoControl.test.tsx => PkgRepoList/PkgRepoControl.test.tsx} (76%) rename dashboard/src/components/Config/{AppRepoList/AppRepoControl.tsx => PkgRepoList/PkgRepoControl.tsx} (52%) rename dashboard/src/components/Config/{AppRepoList/AppRepoDisabledControl.tsx => PkgRepoList/PkgRepoDisabledControl.tsx} (77%) rename dashboard/src/components/Config/{AppRepoList/AppRepoForm.scss => PkgRepoList/PkgRepoForm.scss} (100%) create mode 100644 dashboard/src/components/Config/PkgRepoList/PkgRepoForm.test.tsx create mode 100644 dashboard/src/components/Config/PkgRepoList/PkgRepoForm.tsx rename dashboard/src/components/Config/{AppRepoList/AppRepoList.scss => PkgRepoList/PkgRepoList.scss} (100%) rename dashboard/src/components/Config/{AppRepoList/AppRepoList.test.tsx => PkgRepoList/PkgRepoList.test.tsx} (78%) rename dashboard/src/components/Config/{AppRepoList/AppRepoList.tsx => PkgRepoList/PkgRepoList.tsx} (80%) rename dashboard/src/components/Config/{AppRepoList => PkgRepoList}/index.tsx (56%) delete mode 100644 dashboard/src/shared/AppRepository.test.ts delete mode 100644 dashboard/src/shared/AppRepository.ts create mode 100644 dashboard/src/shared/PackageRepositoriesService.test.ts create mode 100644 dashboard/src/shared/PackageRepositoriesService.ts delete mode 100644 pkg/handlerutil/handlerutil.go delete mode 100644 pkg/handlerutil/handlerutil_test.go diff --git a/chart/kubeapps/templates/kubeops/deployment.yaml b/chart/kubeapps/templates/kubeops/deployment.yaml index 98d6df043bf..4cec1b85917 100644 --- a/chart/kubeapps/templates/kubeops/deployment.yaml +++ b/chart/kubeapps/templates/kubeops/deployment.yaml @@ -88,7 +88,6 @@ spec: args: {{- include "common.tplvalues.render" (dict "value" .Values.kubeops.args "context" $) | nindent 12 }} {{- else }} args: - - --user-agent-comment=kubeapps/{{ .Chart.AppVersion }} {{- if .Values.clusters }} - --clusters-config-path=/config/clusters.conf {{- end }} diff --git a/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server.go b/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server.go index 111fc440e73..a9e42b9c125 100644 --- a/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server.go +++ b/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server.go @@ -21,15 +21,14 @@ import ( corev1 "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/gen/core/packages/v1alpha1" helmv1 "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/gen/plugins/helm/packages/v1alpha1" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/utils" + "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/pkg/agent" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/pkg/clientgetter" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/pkg/paginate" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/pkg/pkgutils" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/pkg/resourcerefs" - "github.com/vmware-tanzu/kubeapps/pkg/agent" chartutils "github.com/vmware-tanzu/kubeapps/pkg/chart" "github.com/vmware-tanzu/kubeapps/pkg/chart/models" "github.com/vmware-tanzu/kubeapps/pkg/dbutils" - "github.com/vmware-tanzu/kubeapps/pkg/handlerutil" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/anypb" @@ -866,7 +865,7 @@ func (s *Server) fetchChartWithRegistrySecrets(ctx context.Context, chartDetails } // Grab the chart itself - ch, err := handlerutil.GetChart( + ch, err := utils.GetChart( &chartutils.Details{ AppRepositoryResourceName: appRepo.Name, AppRepositoryResourceNamespace: appRepo.Namespace, 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 c91f7a16bc3..254297cd8e0 100644 --- a/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server_test.go +++ b/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server_test.go @@ -25,10 +25,10 @@ import ( helmv1 "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/gen/plugins/helm/packages/v1alpha1" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/common" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/utils" + "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/pkg/agent" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/pkg/clientgetter" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/pkg/paginate" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/pkg/pkgutils" - "github.com/vmware-tanzu/kubeapps/pkg/agent" "github.com/vmware-tanzu/kubeapps/pkg/chart/fake" "github.com/vmware-tanzu/kubeapps/pkg/chart/models" "github.com/vmware-tanzu/kubeapps/pkg/dbutils" diff --git a/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/utils/utils.go b/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/utils/utils.go index 73c8f1f93b9..363de36376b 100644 --- a/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/utils/utils.go +++ b/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/utils/utils.go @@ -4,8 +4,12 @@ package utils import ( + appRepov1 "github.com/vmware-tanzu/kubeapps/cmd/apprepository-controller/pkg/apis/apprepository/v1alpha1" + chartUtils "github.com/vmware-tanzu/kubeapps/pkg/chart" "github.com/vmware-tanzu/kubeapps/pkg/chart/models" "github.com/vmware-tanzu/kubeapps/pkg/dbutils" + "helm.sh/helm/v3/pkg/chart" + k8scorev1 "k8s.io/api/core/v1" ) type AssetManager interface { @@ -32,3 +36,16 @@ type ChartQuery struct { func NewManager(databaseType string, config dbutils.Config, globalReposNamespace string) (AssetManager, error) { return NewPGManager(config, globalReposNamespace) } + +// GetChart retrieves a chart +func GetChart(chartDetails *chartUtils.Details, appRepo *appRepov1.AppRepository, caCertSecret *k8scorev1.Secret, authSecret *k8scorev1.Secret, chartClient chartUtils.ChartClient) (*chart.Chart, error) { + err := chartClient.Init(appRepo, caCertSecret, authSecret) + if err != nil { + return nil, err + } + ch, err := chartClient.GetChart(chartDetails, appRepo.Spec.URL) + if err != nil { + return nil, err + } + return ch, nil +} diff --git a/pkg/agent/agent.go b/cmd/kubeapps-apis/plugins/pkg/agent/agent.go similarity index 75% rename from pkg/agent/agent.go rename to cmd/kubeapps-apis/plugins/pkg/agent/agent.go index e290bf626bb..6b7c5ee1541 100644 --- a/pkg/agent/agent.go +++ b/cmd/kubeapps-apis/plugins/pkg/agent/agent.go @@ -22,16 +22,6 @@ import ( "sigs.k8s.io/yaml" ) -type AppOverview struct { - ReleaseName string `json:"releaseName"` - Version string `json:"version"` - Namespace string `json:"namespace"` - Icon string `json:"icon,omitempty"` - Status string `json:"status"` - Chart string `json:"chart"` - ChartMetadata chart.Metadata `json:"chartMetadata"` -} - // StorageForDriver is a function type which returns a specific storage. type StorageForDriver func(namespace string, clientset *kubernetes.Clientset) *storage.Storage @@ -42,43 +32,6 @@ func StorageForSecrets(namespace string, clientset *kubernetes.Clientset) *stora return storage.Init(d) } -// StorageForConfigMaps returns a storage using the ConfigMap driver. -func StorageForConfigMaps(namespace string, clientset *kubernetes.Clientset) *storage.Storage { - d := driver.NewConfigMaps(clientset.CoreV1().ConfigMaps(namespace)) - d.Log = log.Infof - return storage.Init(d) -} - -// StorageForMemory returns a storage using the Memory driver. -func StorageForMemory(_ string, _ *kubernetes.Clientset) *storage.Storage { - d := driver.NewMemory() - return storage.Init(d) -} - -// ListReleases lists releases in the specified namespace, or all namespaces if the empty string is given. -func ListReleases(actionConfig *action.Configuration, namespace string, listLimit int, status string) ([]AppOverview, error) { - allNamespaces := namespace == "" - cmd := action.NewList(actionConfig) - if allNamespaces { - cmd.AllNamespaces = true - } - cmd.Limit = listLimit - if status == "all" { - cmd.StateMask = action.ListAll - } - releases, err := cmd.Run() - if err != nil { - return nil, err - } - appOverviews := make([]AppOverview, 0) - for _, r := range releases { - if allNamespaces || r.Namespace == namespace { - appOverviews = append(appOverviews, appOverviewFromRelease(r)) - } - } - return appOverviews, nil -} - // CreateRelease creates a release. func CreateRelease(actionConfig *action.Configuration, name, namespace, valueString string, ch *chart.Chart, registrySecrets map[string]string, timeoutSeconds int32) (*release.Release, error) { @@ -246,29 +199,3 @@ func getValues(raw []byte) (Values, error) { func stringptr(val string) *string { return &val } - -// ParseDriverType maps strings to well-typed driver representations. -func ParseDriverType(raw string) (StorageForDriver, error) { - switch raw { - case "secret", "secrets": - return StorageForSecrets, nil - case "configmap", "configmaps": - return StorageForConfigMaps, nil - case "memory": - return StorageForMemory, nil - default: - return nil, fmt.Errorf("Invalid Helm driver type: %s", raw) - } -} - -func appOverviewFromRelease(r *release.Release) AppOverview { - return AppOverview{ - ReleaseName: r.Name, - Version: r.Chart.Metadata.Version, - Icon: r.Chart.Metadata.Icon, - Namespace: r.Namespace, - Status: r.Info.Status.String(), - Chart: r.Chart.Name(), - ChartMetadata: *r.Chart.Metadata, - } -} diff --git a/pkg/agent/agent_test.go b/cmd/kubeapps-apis/plugins/pkg/agent/agent_test.go similarity index 54% rename from pkg/agent/agent_test.go rename to cmd/kubeapps-apis/plugins/pkg/agent/agent_test.go index cacde140e2d..ffd471de558 100644 --- a/pkg/agent/agent_test.go +++ b/cmd/kubeapps-apis/plugins/pkg/agent/agent_test.go @@ -5,11 +5,9 @@ package agent import ( "io/ioutil" - "sort" "testing" "time" - "github.com/google/go-cmp/cmp" kubechart "github.com/vmware-tanzu/kubeapps/pkg/chart" chartFake "github.com/vmware-tanzu/kubeapps/pkg/chart/fake" "helm.sh/helm/v3/pkg/action" @@ -19,12 +17,9 @@ import ( "helm.sh/helm/v3/pkg/release" "helm.sh/helm/v3/pkg/storage" "helm.sh/helm/v3/pkg/storage/driver" - "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" ) -const defaultListLimit = 256 - // newActionConfigFixture returns an action.Configuration with fake clients // and memory storage. func newActionConfigFixture(t *testing.T) *action.Configuration { @@ -75,66 +70,6 @@ func makeReleases(t *testing.T, actionConfig *action.Configuration, rels []relea } } -func TestGetRelease(t *testing.T) { - fooApp := releaseStub{"foo", "my_ns", 1, "1.0.0", release.StatusDeployed} - barApp := releaseStub{"bar", "other_ns", 1, "1.0.0", release.StatusDeployed} - testCases := []struct { - description string - existingReleases []releaseStub - targetApp string - targetNamespace string - expectedResult string - shouldFail bool - }{ - { - description: "Get an existing release", - existingReleases: []releaseStub{fooApp, barApp}, - targetApp: "foo", - targetNamespace: "my_ns", - expectedResult: "foo", - }, - { - description: "Get an existing release with default namespace", - existingReleases: []releaseStub{fooApp, barApp}, - targetApp: "foo", - targetNamespace: "", - expectedResult: "foo", - }, - { - description: "Get an non-existing release", - existingReleases: []releaseStub{barApp}, - targetApp: "foo", - targetNamespace: "my_ns", - expectedResult: "", - shouldFail: true, - }, - } - - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - cfg := newActionConfigFixture(t) - makeReleases(t, cfg, tc.existingReleases) - cfg.Releases.Driver.(*driver.Memory).SetNamespace(tc.targetNamespace) - - rls, err := GetRelease(cfg, tc.targetApp) - if tc.shouldFail && err == nil { - t.Errorf("Get %s/%s should fail", tc.targetNamespace, tc.targetApp) - } - if !tc.shouldFail { - if err != nil { - t.Errorf("Unexpected error %v", err) - } - if rls == nil { - t.Fatalf("Release is nil: %v", rls) - } - if rls.Name != tc.expectedResult { - t.Errorf("Expecting app %s, received %s", tc.expectedResult, rls.Name) - } - } - }) - } -} - func TestCreateReleases(t *testing.T) { testCases := []struct { desc string @@ -214,237 +149,6 @@ func TestCreateReleases(t *testing.T) { } } -func TestListReleases(t *testing.T) { - testCases := []struct { - name string - namespace string - listLimit int - status string - releases []releaseStub - expectedApps []AppOverview - }{ - { - name: "returns all apps across namespaces", - namespace: "", - listLimit: defaultListLimit, - releases: []releaseStub{ - {"airwatch", "default", 1, "1.0.0", release.StatusDeployed}, - {"wordpress", "default", 1, "1.0.1", release.StatusDeployed}, - {"not-in-default-namespace", "other", 1, "1.0.2", release.StatusDeployed}, - }, - expectedApps: []AppOverview{ - { - ReleaseName: "airwatch", - Namespace: "default", - Version: "1.0.0", - Status: "deployed", - Icon: "https://example.com/icon.png", - ChartMetadata: chart.Metadata{ - Version: "1.0.0", - Icon: "https://example.com/icon.png", - }, - }, - { - ReleaseName: "wordpress", - Namespace: "default", - Version: "1.0.1", - Status: "deployed", - Icon: "https://example.com/icon.png", - ChartMetadata: chart.Metadata{ - Version: "1.0.1", - Icon: "https://example.com/icon.png", - }, - }, - { - ReleaseName: "not-in-default-namespace", - Namespace: "other", - Version: "1.0.2", - Status: "deployed", - Icon: "https://example.com/icon.png", - ChartMetadata: chart.Metadata{ - Version: "1.0.2", - Icon: "https://example.com/icon.png", - }, - }, - }, - }, - { - name: "returns apps for the given namespace", - namespace: "default", - listLimit: defaultListLimit, - releases: []releaseStub{ - {"airwatch", "default", 1, "1.0.0", release.StatusDeployed}, - {"wordpress", "default", 1, "1.0.1", release.StatusDeployed}, - {"not-in-namespace", "other", 1, "1.0.2", release.StatusDeployed}, - }, - expectedApps: []AppOverview{ - { - ReleaseName: "airwatch", - Namespace: "default", - Version: "1.0.0", - Status: "deployed", - Icon: "https://example.com/icon.png", - ChartMetadata: chart.Metadata{ - Version: "1.0.0", - Icon: "https://example.com/icon.png", - }, - }, - { - ReleaseName: "wordpress", - Namespace: "default", - Version: "1.0.1", - Status: "deployed", - Icon: "https://example.com/icon.png", - ChartMetadata: chart.Metadata{ - Version: "1.0.1", - Icon: "https://example.com/icon.png", - }, - }, - }, - }, - { - name: "returns at most listLimit apps", - namespace: "default", - listLimit: 1, - releases: []releaseStub{ - {"airwatch", "default", 1, "1.0.0", release.StatusDeployed}, - {"wordpress", "default", 1, "1.0.1", release.StatusDeployed}, - {"not-in-namespace", "other", 1, "1.0.2", release.StatusDeployed}, - }, - expectedApps: []AppOverview{ - { - ReleaseName: "airwatch", - Namespace: "default", - Version: "1.0.0", - Status: "deployed", - Icon: "https://example.com/icon.png", - ChartMetadata: chart.Metadata{ - Version: "1.0.0", - Icon: "https://example.com/icon.png", - }, - }, - }, - }, - { - name: "returns two apps with same name but different namespaces and versions", - namespace: "", - listLimit: defaultListLimit, - releases: []releaseStub{ - {"wordpress", "default", 1, "1.0.0", release.StatusDeployed}, - {"wordpress", "dev", 2, "2.0.0", release.StatusDeployed}, - }, - expectedApps: []AppOverview{ - { - ReleaseName: "wordpress", - Namespace: "default", - Version: "1.0.0", - Status: "deployed", - Icon: "https://example.com/icon.png", - ChartMetadata: chart.Metadata{ - Version: "1.0.0", - Icon: "https://example.com/icon.png", - }, - }, - { - ReleaseName: "wordpress", - Namespace: "dev", - Version: "2.0.0", - Status: "deployed", - Icon: "https://example.com/icon.png", - ChartMetadata: chart.Metadata{ - Version: "2.0.0", - Icon: "https://example.com/icon.png", - }, - }, - }, - }, - { - name: "ignore uninstalled apps", - namespace: "", - listLimit: defaultListLimit, - releases: []releaseStub{ - {"wordpress", "default", 1, "1.0.0", release.StatusDeployed}, - {"wordpress", "dev", 2, "1.0.0", release.StatusUninstalled}, - }, - expectedApps: []AppOverview{ - { - ReleaseName: "wordpress", - Namespace: "default", - Version: "1.0.0", - Status: "deployed", - Icon: "https://example.com/icon.png", - ChartMetadata: chart.Metadata{ - Version: "1.0.0", - Icon: "https://example.com/icon.png", - }, - }, - }, - }, - { - name: "include uninstalled apps when requesting all statuses", - namespace: "", - status: "all", - listLimit: defaultListLimit, - releases: []releaseStub{ - {"wordpress", "default", 1, "1.0.0", release.StatusDeployed}, - {"wordpress", "dev", 2, "1.0.1", release.StatusUninstalled}, - }, - expectedApps: []AppOverview{ - { - ReleaseName: "wordpress", - Namespace: "default", - Version: "1.0.0", - Status: "deployed", - Icon: "https://example.com/icon.png", - ChartMetadata: chart.Metadata{ - Version: "1.0.0", - Icon: "https://example.com/icon.png", - }, - }, - { - ReleaseName: "wordpress", - Namespace: "dev", - Version: "1.0.1", - Status: "uninstalled", - Icon: "https://example.com/icon.png", - ChartMetadata: chart.Metadata{ - Version: "1.0.1", - Icon: "https://example.com/icon.png", - }, - }, - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - actionConfig := newActionConfigFixture(t) - makeReleases(t, actionConfig, tc.releases) - actionConfig.Releases.Driver.(*driver.Memory).SetNamespace(tc.namespace) - - apps, err := ListReleases(actionConfig, tc.namespace, tc.listLimit, tc.status) - if err != nil { - t.Errorf("%v", err) - } - - // Check for size of returned apps - if got, want := len(apps), len(tc.expectedApps); got != want { - t.Errorf("got: %d, want: %d", got, want) - } - - // The Helm memory driver does not appear to have consistent ordering. - // See https://github.com/helm/helm/issues/7263 - // Just sort by version which is good enough here. - sort.Slice(apps, func(i, j int) bool { return apps[i].Version < apps[j].Version }) - - //Deep equality check of expected against attained result - if !cmp.Equal(apps, tc.expectedApps) { - t.Errorf(cmp.Diff(apps, tc.expectedApps)) - } - }) - } -} - func TestDeleteRelease(t *testing.T) { testCases := []struct { description string @@ -491,58 +195,6 @@ func TestDeleteRelease(t *testing.T) { } } -func TestParseDriverType(t *testing.T) { - validTestCases := []struct { - input string - driverName string - }{ - { - input: "secret", - driverName: "Secret", - }, - { - input: "secrets", - driverName: "Secret", - }, - { - input: "configmap", - driverName: "ConfigMap", - }, - { - input: "configmaps", - driverName: "ConfigMap", - }, - { - input: "memory", - driverName: "Memory", - }, - } - - for _, tc := range validTestCases { - t.Run(tc.input, func(t *testing.T) { - storageForDriver, err := ParseDriverType(tc.input) - if err != nil { - t.Fatalf("%v", err) - } - storage := storageForDriver("default", &kubernetes.Clientset{}) - if got, want := storage.Name(), tc.driverName; got != want { - t.Errorf("expected: %s, actual: %s", want, got) - } - }) - } - - invalidTestCase := "andresmgot" - t.Run(invalidTestCase, func(t *testing.T) { - storageForDriver, err := ParseDriverType(invalidTestCase) - if err == nil { - t.Errorf("Expected \"%s\" to be an invalid driver type, but it was parsed as %v", invalidTestCase, storageForDriver) - } - if storageForDriver != nil { - t.Errorf("got: %#v, want: nil", storageForDriver) - } - }) -} - func TestRollbackRelease(t *testing.T) { const ( revisionBeingSuperseded = 2 @@ -648,7 +300,7 @@ func TestUpgradeRelease(t *testing.T) { releases: []releaseStub{ {"myrls", "default", revisionBeingUpdated, "mychart", release.StatusDeployed}, }, - valuesYaml: "\\-xx-@myval:\"test value\"\\\n", // ← invalid yaml + valuesYaml: "\\-xx-@myval:\"test value\"\\\n", // <- invalid yaml release: "myrls", chartName: "mynewchart", shouldFail: true, diff --git a/pkg/agent/docker_secrets_postrenderer.go b/cmd/kubeapps-apis/plugins/pkg/agent/docker_secrets_postrenderer.go similarity index 100% rename from pkg/agent/docker_secrets_postrenderer.go rename to cmd/kubeapps-apis/plugins/pkg/agent/docker_secrets_postrenderer.go diff --git a/pkg/agent/docker_secrets_postrenderer_test.go b/cmd/kubeapps-apis/plugins/pkg/agent/docker_secrets_postrenderer_test.go similarity index 100% rename from pkg/agent/docker_secrets_postrenderer_test.go rename to cmd/kubeapps-apis/plugins/pkg/agent/docker_secrets_postrenderer_test.go diff --git a/pkg/agent/httpConfigForCluster.go b/cmd/kubeapps-apis/plugins/pkg/agent/httpConfigForCluster.go similarity index 100% rename from pkg/agent/httpConfigForCluster.go rename to cmd/kubeapps-apis/plugins/pkg/agent/httpConfigForCluster.go diff --git a/cmd/kubeapps-apis/plugins/pkg/clientgetter/clientgetter.go b/cmd/kubeapps-apis/plugins/pkg/clientgetter/clientgetter.go index 6aa2616332f..481165fbae0 100644 --- a/cmd/kubeapps-apis/plugins/pkg/clientgetter/clientgetter.go +++ b/cmd/kubeapps-apis/plugins/pkg/clientgetter/clientgetter.go @@ -7,7 +7,7 @@ import ( "context" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/core" - "github.com/vmware-tanzu/kubeapps/pkg/agent" + "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/pkg/agent" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "helm.sh/helm/v3/pkg/action" diff --git a/cmd/kubeops/cmd/root.go b/cmd/kubeops/cmd/root.go index 46e16f8e2a6..450fa62a1c5 100644 --- a/cmd/kubeops/cmd/root.go +++ b/cmd/kubeops/cmd/root.go @@ -4,8 +4,6 @@ package cmd import ( - "fmt" - "github.com/mitchellh/go-homedir" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -28,10 +26,6 @@ func newRootCmd() *cobra.Command { return &cobra.Command{ Use: "kubeops", Short: "Kubeops is a micro-service that creates an API endpoint for accessing the Helm API and Kubernetes resources.", - PreRun: func(cmd *cobra.Command, args []string) { - serveOpts.UserAgent = getUserAgent(version, serveOpts.UserAgentComment) - log.Infof("kubeops has been configured with: %#v", serveOpts) - }, RunE: func(cmd *cobra.Command, args []string) error { return server.Serve(serveOpts) }, @@ -53,11 +47,6 @@ func init() { } func setFlags(c *cobra.Command) { - c.Flags().StringVar(&serveOpts.HelmDriverArg, "helm-driver", "", "which Helm driver type to use") - c.Flags().IntVar(&serveOpts.ListLimit, "list-max", 256, "maximum number of releases to fetch") - c.Flags().StringVar(&serveOpts.UserAgentComment, "user-agent-comment", "", "UserAgent comment used during outbound requests") - // Default timeout from https://github.com/helm/helm/blob/9fafb4ad6811afb017cc464b630be2ff8390ac63/cmd/helm/install.go#L146 - c.Flags().Int64Var(&serveOpts.Timeout, "timeout", 300, "Timeout to perform release operations (install, upgrade, rollback, delete)") c.Flags().StringVar(&serveOpts.ClustersConfigPath, "clusters-config-path", "", "Configuration for clusters") c.Flags().StringVar(&serveOpts.PinnipedProxyURL, "pinniped-proxy-url", "http://kubeapps-internal-pinniped-proxy.kubeapps:3333", "internal url to be used for requests to clusters configured for credential proxying via pinniped") c.Flags().StringVar(&serveOpts.PinnipedProxyCACert, "pinniped-proxy-ca-cert", "", "Path to certificate authority to use with requests to pinniped-proxy service") @@ -65,7 +54,10 @@ func setFlags(c *cobra.Command) { c.Flags().Float32Var(&serveOpts.Qps, "qps", 10, "internal QPS rate") c.Flags().StringVar(&serveOpts.NamespaceHeaderName, "namespace-header-name", "", "name of the header field, e.g. namespace-header-name=X-Consumer-Groups") c.Flags().StringVar(&serveOpts.NamespaceHeaderPattern, "namespace-header-pattern", "", "regular expression that matches only single group, e.g. namespace-header-pattern=^namespace:([\\w]+):\\w+$, to match namespace:ns:read") - c.Flags().StringVar(&serveOpts.GlobalReposNamespace, "global-repos-namespace", "kubeapps", "Namespace of global repositories") + + // TODO(agamez): remove once a new version of the chart is released + var deprecated string + c.Flags().StringVar(&deprecated, "user-agent-comment", "", "(deprecated) UserAgent comment used during outbound requests") } // initConfig reads in config file and ENV variables if set. @@ -91,15 +83,3 @@ func initConfig() { log.Errorf("Using config file: %v", viper.ConfigFileUsed()) } } - -// Returns the user agent to be used during calls to the chart repositories -// Examples: -// kubeops/devel -// kubeops/2.3.4 (kubeapps v2.3.4-beta4) -func getUserAgent(version, userAgentComment string) string { - ua := "kubeops/" + version - if userAgentComment != "" { - ua = fmt.Sprintf("%s (%s)", ua, userAgentComment) - } - return ua -} diff --git a/cmd/kubeops/cmd/root_test.go b/cmd/kubeops/cmd/root_test.go index 199210c57d2..8819e74615b 100644 --- a/cmd/kubeops/cmd/root_test.go +++ b/cmd/kubeops/cmd/root_test.go @@ -20,11 +20,6 @@ func TestParseFlagsCorrect(t *testing.T) { { "all arguments are captured", []string{ - - "--helm-driver", "foo02", - "--list-max", "901", - "--user-agent-comment", "foo03", - "--timeout", "902", "--clusters-config-path", "foo04", "--pinniped-proxy-url", "foo05", "--pinniped-proxy-ca-cert", "/etc/foo/my-ca.crt", @@ -32,13 +27,8 @@ func TestParseFlagsCorrect(t *testing.T) { "--qps", "904", "--namespace-header-name", "foo06", "--namespace-header-pattern", "foo07", - "--global-repos-namespace", "kubeapps-global", }, server.ServeOptions{ - HelmDriverArg: "foo02", - ListLimit: 901, - UserAgentComment: "foo03", - Timeout: 902, ClustersConfigPath: "foo04", PinnipedProxyURL: "foo05", PinnipedProxyCACert: "/etc/foo/my-ca.crt", @@ -46,8 +36,6 @@ func TestParseFlagsCorrect(t *testing.T) { Qps: 904, NamespaceHeaderName: "foo06", NamespaceHeaderPattern: "foo07", - UserAgent: "kubeops/devel (foo03)", - GlobalReposNamespace: "kubeapps-global", }, }, } @@ -67,33 +55,3 @@ func TestParseFlagsCorrect(t *testing.T) { }) } } - -func TestGetUserAgent(t *testing.T) { - testCases := []struct { - name string - version string - comment string - expected string - }{ - { - name: "creates a user agent without a comment", - version: "2.1.6", - expected: "kubeops/2.1.6", - }, - { - name: "creates a user agent with comment", - version: "2.1.6", - comment: "foobar", - expected: "kubeops/2.1.6 (foobar)", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - if got, want := getUserAgent(tc.version, tc.comment), tc.expected; got != want { - t.Errorf("got: %q, want: %q", got, want) - } - }) - } - -} diff --git a/cmd/kubeops/internal/auth/auth.go b/cmd/kubeops/internal/auth/auth.go deleted file mode 100644 index e6d9fd4aabf..00000000000 --- a/cmd/kubeops/internal/auth/auth.go +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright 2018-2022 the Kubeapps contributors. -// SPDX-License-Identifier: Apache-2.0 - -package auth - -import ( - "fmt" - "regexp" -) - -// Action represents a specific set of verbs against a resource -type Action struct { - APIVersion string `json:"apiGroup"` - Resource string `json:"resource"` - Namespace string `json:"namespace"` - ClusterWide bool `json:"clusterWide"` - Verbs []string `json:"verbs"` -} - -func uniqVerbs(current []string, new []string) []string { - resMap := map[string]bool{} - for _, v := range current { - if !resMap[v] { - resMap[v] = true - } - } - res := append([]string{}, current...) - for _, v := range new { - if !resMap[v] { - resMap[v] = true - res = append(res, v) - } - } - return res -} - -func reduceActionsByVerb(actions []Action) []Action { - resMap := map[string]Action{} - for _, action := range actions { - req := fmt.Sprintf("%s/%s/%s", action.Namespace, action.APIVersion, action.Resource) - if _, ok := resMap[req]; ok { - // Element already exists - resMap[req] = Action{ - APIVersion: action.APIVersion, - Resource: action.Resource, - Namespace: action.Namespace, - Verbs: uniqVerbs(resMap[req].Verbs, action.Verbs), - } - } else { - resMap[req] = action - } - } - res := []Action{} - for _, a := range resMap { - res = append(res, a) - } - return res -} - -// ParseForbiddenActions parses a forbidden error returned by the Kubernetes API and return the list of forbidden actions -func ParseForbiddenActions(message string) []Action { - // TODO(andresmgot): Helm may not return all the required permissions in the same message. At the moment of writing this - // the only supported format is an error string so we can only parse the message with a regex - // More info: https://github.com/helm/helm/issues/7453 - re := regexp.MustCompile(`User "(.*?)" cannot (.*?) resource "(.*?)" in API group "(.*?)"(?: in the namespace "(.*?)")?`) - match := re.FindAllStringSubmatch(message, -1) - forbiddenActions := []Action{} - for _, role := range match { - forbiddenActions = append(forbiddenActions, Action{ - // TODO(andresmgot): Return the user/serviceaccount trying to perform the action - Verbs: []string{role[2]}, - Resource: role[3], - APIVersion: role[4], - Namespace: role[5], - ClusterWide: role[5] == "", - }) - } - return reduceActionsByVerb(forbiddenActions) -} diff --git a/cmd/kubeops/internal/auth/auth_test.go b/cmd/kubeops/internal/auth/auth_test.go deleted file mode 100644 index 60da4f3a381..00000000000 --- a/cmd/kubeops/internal/auth/auth_test.go +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright 2018-2022 the Kubeapps contributors. -// SPDX-License-Identifier: Apache-2.0 - -package auth - -import ( - "strings" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" -) - -func TestParseForbiddenActions(t *testing.T) { - testSuite := []struct { - Description string - Error string - ExpectedActions []Action - }{ - { - "parses an error with a single resource", - `User "foo" cannot create resource "secrets" in API group "" in the namespace "default"`, - []Action{ - {APIVersion: "", Resource: "secrets", Namespace: "default", Verbs: []string{"create"}}, - }, - }, - { - "parses an error with a cluster-wide resource", - `User "foo" cannot create resource "clusterroles" in API group "v1"`, - []Action{ - {APIVersion: "v1", Resource: "clusterroles", Namespace: "", Verbs: []string{"create"}, ClusterWide: true}, - }, - }, - { - "parses several resources", - `User "foo" cannot create resource "secrets" in API group "" in the namespace "default"; - User "foo" cannot create resource "pods" in API group "" in the namespace "default"`, - []Action{ - {APIVersion: "", Resource: "secrets", Namespace: "default", Verbs: []string{"create"}}, - {APIVersion: "", Resource: "pods", Namespace: "default", Verbs: []string{"create"}}, - }, - }, - { - "includes different verbs and remove duplicates", - `User "foo" cannot create resource "secrets" in API group "" in the namespace "default"; - User "foo" cannot create resource "secrets" in API group "" in the namespace "default"; - User "foo" cannot delete resource "secrets" in API group "" in the namespace "default"`, - []Action{ - {APIVersion: "", Resource: "secrets", Namespace: "default", Verbs: []string{"create", "delete"}}, - }, - }, - } - for _, test := range testSuite { - t.Run(test.Description, func(t *testing.T) { - actions := ParseForbiddenActions(test.Error) - // order actions by resource - less := func(x, y Action) bool { return strings.Compare(x.Resource, y.Resource) < 0 } - if !cmp.Equal(actions, test.ExpectedActions, cmpopts.SortSlices(less)) { - t.Errorf("Unexpected forbidden actions: %v", cmp.Diff(actions, test.ExpectedActions)) - } - }) - } -} diff --git a/cmd/kubeops/internal/auth/authgate.go b/cmd/kubeops/internal/auth/authgate.go deleted file mode 100644 index 4365a18d42c..00000000000 --- a/cmd/kubeops/internal/auth/authgate.go +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2019-2022 the Kubeapps contributors. -// SPDX-License-Identifier: Apache-2.0 - -package auth - -import ( - "strings" -) - -// tokenPrefix is the string preceding the token in the Authorization header. -const tokenPrefix = "Bearer " - -// ExtractToken extracts the token from a correctly formatted Authorization header. -func ExtractToken(headerValue string) string { - if strings.HasPrefix(headerValue, tokenPrefix) { - return headerValue[len(tokenPrefix):] - } else { - return "" - } -} diff --git a/cmd/kubeops/internal/handler/handler.go b/cmd/kubeops/internal/handler/handler.go deleted file mode 100644 index 09fc9215950..00000000000 --- a/cmd/kubeops/internal/handler/handler.go +++ /dev/null @@ -1,319 +0,0 @@ -// Copyright 2019-2022 the Kubeapps contributors. -// SPDX-License-Identifier: Apache-2.0 - -package handler - -import ( - "encoding/json" - "fmt" - "net/http" - "strconv" - - "github.com/gorilla/mux" - negroni "github.com/urfave/negroni/v2" - "github.com/vmware-tanzu/kubeapps/cmd/kubeops/internal/auth" - "github.com/vmware-tanzu/kubeapps/cmd/kubeops/internal/response" - "github.com/vmware-tanzu/kubeapps/pkg/agent" - "github.com/vmware-tanzu/kubeapps/pkg/chart" - chartUtils "github.com/vmware-tanzu/kubeapps/pkg/chart" - "github.com/vmware-tanzu/kubeapps/pkg/handlerutil" - "github.com/vmware-tanzu/kubeapps/pkg/kube" - "helm.sh/helm/v3/pkg/action" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/rest" - log "k8s.io/klog/v2" -) - -const ( - authHeader = "Authorization" - clusterParam = "cluster" - namespaceParam = "namespace" - nameParam = "releaseName" - authUserError = "Unexpected error while configuring authentication" -) - -// This type represents the fact that a regular handler cannot actually be created until we have access to the request, -// because a valid action config (and hence handler config) cannot be created until then. -// If the handler config were a "this" argument instead of an explicit argument, it would be easy to create a handler with a "zero" config. -// This approach practically eliminates that risk; it is much easier to use WithHandlerConfig to create a handler guaranteed to use a valid handler config. -type dependentHandler func(cfg Config, w http.ResponseWriter, req *http.Request, params handlerutil.Params) - -// Options represents options that can be created without a bearer token, i.e. once at application startup. -type Options struct { - ListLimit int - Timeout int64 - UserAgent string - KubeappsNamespace string - ClustersConfig kube.ClustersConfig - Burst int - QPS float32 - NamespaceHeaderName string - NamespaceHeaderPattern string -} - -// Config represents data needed by each handler to be able to create Helm 3 actions. -// It cannot be created without a bearer token, so a new one must be created upon each HTTP request. -type Config struct { - ActionConfig *action.Configuration - Options Options - KubeHandler kube.AuthHandler - ChartClientFactory chartUtils.ChartClientFactoryInterface - Cluster string - Token string - userClientSet kubernetes.Interface -} - -// WithHandlerConfig takes a dependentHandler and creates a regular (WithParams) handler that, -// for every request, will create a handler config for itself. -// Written in a curried fashion for convenient usage; see cmd/kubeops/main.go. -func WithHandlerConfig(storageForDriver agent.StorageForDriver, options Options) func(f dependentHandler) handlerutil.WithParams { - return func(f dependentHandler) handlerutil.WithParams { - return func(w http.ResponseWriter, req *http.Request, params handlerutil.Params) { - // Don't assume the cluster name was in the url for backwards compatibility - // for now. - cluster, ok := params[clusterParam] - if !ok { - cluster = options.ClustersConfig.KubeappsClusterName - } - namespace := params[namespaceParam] - token := auth.ExtractToken(req.Header.Get(authHeader)) - - inClusterConfig, err := rest.InClusterConfig() - if err != nil { - log.Errorf("Failed to create in-cluster config: %v", err) - response.NewErrorResponse(http.StatusInternalServerError, authUserError).Write(w) - return - } - - restConfig, err := kube.NewClusterConfig(inClusterConfig, token, cluster, options.ClustersConfig) - if err != nil { - log.Errorf("Failed to create in-cluster config with user token: %v", err) - response.NewErrorResponse(http.StatusInternalServerError, authUserError).Write(w) - return - } - userKubeClient, err := kubernetes.NewForConfig(restConfig) - if err != nil { - log.Errorf("Failed to create kube client with user config: %v", err) - response.NewErrorResponse(http.StatusInternalServerError, authUserError).Write(w) - return - } - actionConfig, err := agent.NewActionConfig(storageForDriver, restConfig, userKubeClient, namespace) - if err != nil { - log.Errorf("Failed to create action config with user client: %v", err) - response.NewErrorResponse(http.StatusInternalServerError, authUserError).Write(w) - return - } - - kubeHandler, err := kube.NewHandler(options.KubeappsNamespace, options.NamespaceHeaderName, options.NamespaceHeaderPattern, options.Burst, options.QPS, options.ClustersConfig) - if err != nil { - log.Errorf("Failed to create handler: %v", err) - response.NewErrorResponse(http.StatusInternalServerError, authUserError).Write(w) - return - } - - cfg := Config{ - Options: options, - ActionConfig: actionConfig, - KubeHandler: kubeHandler, - Cluster: cluster, - Token: token, - ChartClientFactory: &chartUtils.ChartClientFactory{}, - userClientSet: userKubeClient, - } - f(cfg, w, req, params) - } - } -} - -// AddRouteWith makes it easier to define routes in main.go and avoids code repetition. -func AddRouteWith( - r *mux.Router, - withHandlerConfig func(dependentHandler) handlerutil.WithParams, -) func(verb, path string, handler dependentHandler) { - return func(verb, path string, handler dependentHandler) { - r.Methods(verb).Path(path).Handler(negroni.New(negroni.Wrap(withHandlerConfig(handler)))) - } -} - -func returnForbiddenActions(forbiddenActions []auth.Action, w http.ResponseWriter) { - w.Header().Set("Content-Type", "application/json") - body, err := json.Marshal(forbiddenActions) - if err != nil { - returnErrMessage(err, w) - return - } - response.NewErrorResponse(http.StatusForbidden, string(body)).Write(w) -} - -func returnErrMessage(err error, w http.ResponseWriter) { - code := handlerutil.ErrorCode(err) - errMessage := err.Error() - if code == http.StatusForbidden { - forbiddenActions := auth.ParseForbiddenActions(errMessage) - if len(forbiddenActions) > 0 { - returnForbiddenActions(forbiddenActions, w) - } else { - // Unable to parse forbidden actions, return the raw message - response.NewErrorResponse(code, errMessage).Write(w) - } - } else { - response.NewErrorResponse(code, errMessage).Write(w) - } -} - -// ListReleases list existing releases. -func ListReleases(cfg Config, w http.ResponseWriter, req *http.Request, params handlerutil.Params) { - apps, err := agent.ListReleases(cfg.ActionConfig, params[namespaceParam], cfg.Options.ListLimit, req.URL.Query().Get("statuses")) - if err != nil { - returnErrMessage(err, w) - return - } - response.NewDataResponse(apps).Write(w) -} - -// ListAllReleases list all the releases available. -func ListAllReleases(cfg Config, w http.ResponseWriter, req *http.Request, _ handlerutil.Params) { - ListReleases(cfg, w, req, make(map[string]string)) -} - -// CreateRelease creates a release. -func CreateRelease(cfg Config, w http.ResponseWriter, req *http.Request, params handlerutil.Params) { - chartDetails, err := handlerutil.ParseRequest(req) - if err != nil { - returnErrMessage(err, w) - return - } - // TODO: currently app repositories are only supported on the cluster on which Kubeapps is installed. #1982 - appRepo, caCertSecret, authSecret, err := chart.GetAppRepoAndRelatedSecrets(chartDetails.AppRepositoryResourceName, chartDetails.AppRepositoryResourceNamespace, cfg.KubeHandler, cfg.Token, cfg.Options.ClustersConfig.KubeappsClusterName, cfg.Options.ClustersConfig.GlobalReposNamespace, cfg.Options.ClustersConfig.KubeappsClusterName) - if err != nil { - returnErrMessage(fmt.Errorf("unable to get app repository %q: %v", chartDetails.AppRepositoryResourceName, err), w) - return - } - ch, err := handlerutil.GetChart( - chartDetails, - appRepo, - caCertSecret, authSecret, - cfg.ChartClientFactory.New(appRepo.Spec.Type, cfg.Options.UserAgent), - ) - if err != nil { - returnErrMessage(err, w) - return - } - - releaseName := chartDetails.ReleaseName - namespace := params[namespaceParam] - valuesString := chartDetails.Values - registrySecrets, err := chartUtils.RegistrySecretsPerDomain(req.Context(), appRepo.Spec.DockerRegistrySecrets, appRepo.Namespace, cfg.userClientSet) - if err != nil { - returnErrMessage(err, w) - return - } - release, err := agent.CreateRelease(cfg.ActionConfig, releaseName, namespace, valuesString, ch, registrySecrets, 0) - if err != nil { - returnErrMessage(err, w) - return - } - response.NewDataResponse(release).Write(w) -} - -// OperateRelease decides which method to call depending on the "action" query param. -func OperateRelease(cfg Config, w http.ResponseWriter, req *http.Request, params handlerutil.Params) { - switch req.FormValue("action") { - case "upgrade": - upgradeRelease(cfg, w, req, params) - case "rollback": - rollbackRelease(cfg, w, req, params) - // TODO: Add "test" case here. - default: - // By default, for maintaining compatibility, we call upgrade. - upgradeRelease(cfg, w, req, params) - } -} - -func upgradeRelease(cfg Config, w http.ResponseWriter, req *http.Request, params handlerutil.Params) { - releaseName := params[nameParam] - chartDetails, err := handlerutil.ParseRequest(req) - if err != nil { - returnErrMessage(err, w) - return - } - // TODO: currently app repositories are only supported on the cluster on which Kubeapps is installed. #1982 - appRepo, caCertSecret, authSecret, err := chart.GetAppRepoAndRelatedSecrets(chartDetails.AppRepositoryResourceName, chartDetails.AppRepositoryResourceNamespace, cfg.KubeHandler, cfg.Token, cfg.Options.ClustersConfig.KubeappsClusterName, cfg.Options.KubeappsNamespace, cfg.Options.ClustersConfig.KubeappsClusterName) - if err != nil { - returnErrMessage(fmt.Errorf("unable to get app repository %q: %v", chartDetails.AppRepositoryResourceName, err), w) - return - } - ch, err := handlerutil.GetChart( - chartDetails, - appRepo, - caCertSecret, authSecret, - cfg.ChartClientFactory.New(appRepo.Spec.Type, cfg.Options.UserAgent), - ) - if err != nil { - returnErrMessage(err, w) - return - } - registrySecrets, err := chartUtils.RegistrySecretsPerDomain(req.Context(), appRepo.Spec.DockerRegistrySecrets, appRepo.Namespace, cfg.userClientSet) - if err != nil { - returnErrMessage(err, w) - return - } - - rel, err := agent.UpgradeRelease(cfg.ActionConfig, releaseName, chartDetails.Values, ch, registrySecrets, 0) - if err != nil { - returnErrMessage(err, w) - return - } - response.NewDataResponse(*rel).Write(w) -} - -func rollbackRelease(cfg Config, w http.ResponseWriter, req *http.Request, params handlerutil.Params) { - releaseName := params[nameParam] - revision := req.FormValue("revision") - if revision == "" { - response.NewErrorResponse(http.StatusUnprocessableEntity, "Missing revision to rollback in request").Write(w) - return - } - revisionInt, err := strconv.ParseInt(revision, 10, 32) - if err != nil { - returnErrMessage(err, w) - return - } - rel, err := agent.RollbackRelease(cfg.ActionConfig, releaseName, int(revisionInt), 0) - if err != nil { - returnErrMessage(err, w) - return - } - response.NewDataResponse(*rel).Write(w) -} - -// GetRelease returns a release. -func GetRelease(cfg Config, w http.ResponseWriter, req *http.Request, params handlerutil.Params) { - // Namespace is already known by the RESTClientGetter. - releaseName := params[nameParam] - release, err := agent.GetRelease(cfg.ActionConfig, releaseName) - if err != nil { - returnErrMessage(err, w) - return - } - response.NewDataResponse(*release).Write(w) -} - -// DeleteRelease deletes a release. -func DeleteRelease(cfg Config, w http.ResponseWriter, req *http.Request, params handlerutil.Params) { - releaseName := params[nameParam] - purge := handlerutil.QueryParamIsTruthy("purge", req) - // Helm 3 has --purge by default; --keep-history in Helm 3 corresponds to omitting --purge in Helm 2. - // https://stackoverflow.com/a/59210923/2135002 - keepHistory := !purge - err := agent.DeleteRelease(cfg.ActionConfig, releaseName, keepHistory, 0) - if err != nil { - returnErrMessage(err, w) - return - } - w.Header().Set("Status-Code", "200") - _, err = w.Write([]byte("OK")) - if err != nil { - return - } -} diff --git a/cmd/kubeops/internal/handler/handler_test.go b/cmd/kubeops/internal/handler/handler_test.go deleted file mode 100644 index 132138be7ca..00000000000 --- a/cmd/kubeops/internal/handler/handler_test.go +++ /dev/null @@ -1,540 +0,0 @@ -// Copyright 2020-2022 the Kubeapps contributors. -// SPDX-License-Identifier: Apache-2.0 - -package handler - -import ( - "errors" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "regexp" - "sort" - "strings" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "github.com/vmware-tanzu/kubeapps/cmd/apprepository-controller/pkg/apis/apprepository/v1alpha1" - fakeChartUtils "github.com/vmware-tanzu/kubeapps/pkg/chart/fake" - kubeappsKube "github.com/vmware-tanzu/kubeapps/pkg/kube" - "helm.sh/helm/v3/pkg/action" - "helm.sh/helm/v3/pkg/chart" - "helm.sh/helm/v3/pkg/chartutil" - kubefake "helm.sh/helm/v3/pkg/kube/fake" - "helm.sh/helm/v3/pkg/storage" - "helm.sh/helm/v3/pkg/storage/driver" - helmTime "helm.sh/helm/v3/pkg/time" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "helm.sh/helm/v3/pkg/release" -) - -const ( - defaultListLimit = 256 -) - -var ( - testingTime, _ = helmTime.Parse(time.RFC3339, "1977-09-02T22:04:05Z") - lastDeployedRegex = regexp.MustCompile(`"last_deployed":\s*"[^"]+?[^\/"]+"`) -) - -// newConfigFixture returns a Config with fake clients -// and memory storage. -func newConfigFixture(t *testing.T, k *kubefake.FailingKubeClient) *Config { - t.Helper() - - return &Config{ - ActionConfig: &action.Configuration{ - Releases: storage.Init(driver.NewMemory()), - KubeClient: k, - Capabilities: chartutil.DefaultCapabilities, - Log: func(format string, v ...interface{}) { - t.Helper() - t.Logf(format, v...) - }, - }, - KubeHandler: &kubeappsKube.FakeHandler{ - AppRepos: []*v1alpha1.AppRepository{ - {ObjectMeta: v1.ObjectMeta{Name: "bitnami", Namespace: "default"}, - Spec: v1alpha1.AppRepositorySpec{Type: "helm", URL: "http://foo.bar"}, - }, - }, - }, - ChartClientFactory: &fakeChartUtils.ChartClientFactory{}, - Options: Options{ - ListLimit: defaultListLimit, - }, - } -} - -// See https://github.com/vmware-tanzu/kubeapps/pull/1439/files#r365678777 -// for discussion about cleaner long booleans. -func and(exps ...bool) bool { - for _, exp := range exps { - if !exp { - return false - } - } - return true -} - -var releaseComparer = cmp.Comparer(func(x release.Release, y release.Release) bool { - return and( - x.Name == y.Name, - x.Version == y.Version, - x.Namespace == y.Namespace, - x.Info.Status == y.Info.Status, - x.Chart.Name() == y.Chart.Name(), - x.Manifest == y.Manifest, - cmp.Equal(x.Config, y.Config), - cmp.Equal(x.Hooks, y.Hooks), - ) -}) - -func TestActions(t *testing.T) { - type testScenario struct { - // Scenario params - Description string - ExistingReleases []*release.Release - KubeError error - // Request params - RequestBody string - RequestQuery string - Action string - Params map[string]string - // Expected result - StatusCode int - RemainingReleases []*release.Release - ResponseBody string //optional - } - - tests := []testScenario{ - { - // Scenario params - Description: "Create a simple release", - ExistingReleases: []*release.Release{}, - // Request params - RequestBody: `{"chartName": "foo", "releaseName": "foobar", "version": "1.0.0", "appRepositoryResourceName": "bitnami", "appRepositoryResourceNamespace": "default"}`, - RequestQuery: "", - Action: "create", - Params: map[string]string{"namespace": "default"}, - // Expected result - StatusCode: 200, - RemainingReleases: []*release.Release{ - createRelease("foo", "foobar", "default", 1, release.StatusDeployed), - }, - ResponseBody: "", - }, - { - // Scenario params - Description: "Create a conflicting release", - ExistingReleases: []*release.Release{ - createRelease("foo", "foobar", "default", 1, release.StatusDeployed), - }, - // Request params - RequestBody: `{"chartName": "foo", "releaseName": "foobar", "version": "1.0.0", "appRepositoryResourceName": "bitnami", "appRepositoryResourceNamespace": "default"}`, - RequestQuery: "", - Action: "create", - Params: map[string]string{"namespace": "default"}, - // Expected result - StatusCode: 409, - RemainingReleases: []*release.Release{ - createRelease("foo", "foobar", "default", 1, release.StatusDeployed), - }, - ResponseBody: "", - }, - { - // Scenario params - Description: "Get a non-existing release", - ExistingReleases: []*release.Release{}, - // Request params - RequestBody: "", - RequestQuery: "", - Action: "get", - Params: map[string]string{"namespace": "default", "releaseName": "foobar"}, - // Expected result - StatusCode: 404, - RemainingReleases: nil, - ResponseBody: "", - }, - { - Description: "Delete a simple release", - ExistingReleases: []*release.Release{ - createRelease("foobarchart", "foobar", "default", 1, release.StatusDeployed), - }, - // Request params - RequestBody: "", - RequestQuery: "", - Action: "delete", - Params: map[string]string{"namespace": "default", "releaseName": "foobar"}, - // Expected result - StatusCode: 200, - RemainingReleases: []*release.Release{ - createRelease("foobarchart", "foobar", "default", 1, release.StatusUninstalled), - }, - ResponseBody: "", - }, - { - // Scenario params - Description: "Delete and purge a simple release with purge=true", - ExistingReleases: []*release.Release{ - createRelease("foobarchart", "foobar", "default", 1, release.StatusDeployed), - }, - // Request params - RequestBody: "", - RequestQuery: "?purge=true", - Action: "delete", - Params: map[string]string{"namespace": "default", "releaseName": "foobar"}, - // Expected result - StatusCode: 200, - RemainingReleases: nil, - ResponseBody: "", - }, - { - // Scenario params - Description: "Get a simple release", - ExistingReleases: []*release.Release{ - createRelease("foo", "foobar", "default", 1, release.StatusDeployed), - }, - // Request params - RequestBody: "", - RequestQuery: "", - Action: "get", - Params: map[string]string{"namespace": "default", "releaseName": "foobar"}, - // Expected result - StatusCode: 200, - RemainingReleases: []*release.Release{ - createRelease("foo", "foobar", "default", 1, release.StatusDeployed), - }, - ResponseBody: `{"data":{"name":"foobar","info":{"first_deployed":"1977-09-02T22:04:05Z","last_deployed":"1977-09-02T22:04:05Z","deleted":"","status":"deployed"},"chart":{"metadata":{"name":"foo"},"lock":null,"templates":null,"values":{},"schema":null,"files":null},"version":1,"namespace":"default"}}`, - }, - { - // Scenario params - Description: "Get a deleted release", - ExistingReleases: []*release.Release{ - createRelease("foo", "foobar", "default", 1, release.StatusUninstalled), - }, - // Request params - RequestBody: "", - RequestQuery: "", - Action: "get", - Params: map[string]string{"namespace": "default", "releaseName": "foobar"}, - // Expected result - StatusCode: 200, - RemainingReleases: []*release.Release{ - createRelease("foo", "foobar", "default", 1, release.StatusUninstalled), - }, - ResponseBody: `{"data":{"name":"foobar","info":{"first_deployed":"1977-09-02T22:04:05Z","last_deployed":"1977-09-02T22:04:05Z","deleted":"1977-09-02T22:04:05Z","status":"uninstalled"},"chart":{"metadata":{"name":"foo"},"lock":null,"templates":null,"values":{},"schema":null,"files":null},"version":1,"namespace":"default"}}`, - }, - { - // Scenario params - Description: "Delete and purge a simple release with purge=1", - ExistingReleases: []*release.Release{ - createRelease("foobarchart", "foobar", "default", 1, release.StatusDeployed), - }, - // Request params - RequestBody: "", - RequestQuery: "?purge=1", - Action: "delete", - Params: map[string]string{"namespace": "default", "releaseName": "foobar"}, - // Expected result - StatusCode: 200, - RemainingReleases: nil, - ResponseBody: "", - }, - { - // Scenario params - Description: "Delete a missing release", - ExistingReleases: []*release.Release{}, - // Request params - RequestBody: "", - RequestQuery: "", - Action: "delete", - Params: map[string]string{"namespace": "default", "releaseName": "foobar"}, - // Expected result - StatusCode: 404, - RemainingReleases: nil, - ResponseBody: "", - }, - { - // Scenario params - Description: "Creates a release with missing permissions", - ExistingReleases: []*release.Release{}, - KubeError: errors.New(`Failed to create: secrets is forbidden: User "foo" cannot create resource "secrets" in API group "" in the namespace "default"`), - // Request params - RequestBody: `{"chartName": "foo", "releaseName": "foobar", "version": "1.0.0", "appRepositoryResourceName": "bitnami", "appRepositoryResourceNamespace": "default"}`, - RequestQuery: "", - Action: "create", - Params: map[string]string{"namespace": "default"}, - // Expected result - StatusCode: 403, - RemainingReleases: nil, - ResponseBody: `{"code":403,"message":"[{\"apiGroup\":\"\",\"resource\":\"secrets\",\"namespace\":\"default\",\"clusterWide\":false,\"verbs\":[\"create\"]}]"}`, - }, - } - - for _, test := range tests { - t.Run(test.Description, func(t *testing.T) { - // Initialize environment for test - req := httptest.NewRequest("GET", fmt.Sprintf("http://foo.bar%s", test.RequestQuery), strings.NewReader(test.RequestBody)) - response := httptest.NewRecorder() - k := &kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: ioutil.Discard}} - if test.KubeError != nil { - // The helm fake Kube Client runs build before - // create/install/upgrade. It also stores a release in storage - // even if there were no resources to create so we need to error - // before the release is saved. - k.BuildError = test.KubeError - } - cfg := newConfigFixture(t, k) - createExistingReleases(t, cfg, test.ExistingReleases) - - // Perform request - switch test.Action { - case "get": - GetRelease(*cfg, response, req, test.Params) - case "create": - CreateRelease(*cfg, response, req, test.Params) - case "delete": - DeleteRelease(*cfg, response, req, test.Params) - default: - t.Errorf("Unexpected action %s", test.Action) - } - // Check result - if response.Code != test.StatusCode { - t.Errorf("Expecting a StatusCode %d, received %d", test.StatusCode, response.Code) - } - releases, err := cfg.ActionConfig.Releases.ListReleases() - if err != nil { - t.Fatalf("%+v", err) - } - // The Helm memory driver does not appear to have consistent ordering. - // See https://github.com/helm/helm/issues/7263 - // Just sort by "name.version.namespace" which is good enough here. - sort.Slice(releases, func(i, j int) bool { - iKey := fmt.Sprintf("%s.%d.%s", releases[i].Name, releases[i].Version, releases[i].Namespace) - jKey := fmt.Sprintf("%s.%d.%s", releases[j].Name, releases[j].Version, releases[j].Namespace) - return iKey < jKey - }) - if !cmp.Equal(releases, test.RemainingReleases, releaseComparer) { - t.Errorf("Unexpected remaining releases. Diff:\n%s", cmp.Diff(test.RemainingReleases, releases, releaseComparer)) - } - if test.ResponseBody != "" { - if test.ResponseBody != response.Body.String() { - t.Errorf("Unexpected body response. Diff %s", cmp.Diff(test.ResponseBody, response.Body)) - } - } - }) - } -} - -func createRelease(chartName, name, namespace string, version int, status release.Status) *release.Release { - deleted := helmTime.Time{} - if status == release.StatusUninstalled { - deleted = testingTime - } - return &release.Release{ - Name: name, - Namespace: namespace, - Version: version, - Info: &release.Info{Status: status, Deleted: deleted, FirstDeployed: testingTime, LastDeployed: testingTime}, - Chart: &chart.Chart{ - Metadata: &chart.Metadata{ - Name: chartName, - }, - Values: make(map[string]interface{}), - }, - Config: make(map[string]interface{}), - } -} - -func createExistingReleases(t *testing.T, cfg *Config, releases []*release.Release) { - for i := range releases { - err := cfg.ActionConfig.Releases.Create(releases[i]) - if err != nil { - t.Fatalf("%+v", err) - } - } -} - -func TestRollbackAction(t *testing.T) { - const releaseName = "my-release" - testCases := []struct { - name string - existingReleases []*release.Release - queryString string - params map[string]string - statusCode int - expectedReleases []*release.Release - responseBody string - }{ - { - name: "rolls back a release", - existingReleases: []*release.Release{ - createRelease("apache", releaseName, "default", 1, release.StatusSuperseded), - createRelease("apache", releaseName, "default", 2, release.StatusDeployed), - }, - queryString: "action=rollback&revision=1", - params: map[string]string{nameParam: "my-release"}, - statusCode: http.StatusOK, - expectedReleases: []*release.Release{ - createRelease("apache", releaseName, "default", 1, release.StatusSuperseded), - createRelease("apache", releaseName, "default", 2, release.StatusSuperseded), - createRelease("apache", releaseName, "default", 3, release.StatusDeployed), - }, - responseBody: `{"data":{"name":"my-release","info":{"first_deployed":"1977-09-02T22:04:05Z","last_deployed":"1977-09-02T22:04:05Z","deleted":"","description":"Rollback to 1","status":"deployed"},"chart":{"metadata":{"name":"apache"},"lock":null,"templates":null,"values":{},"schema":null,"files":null},"version":3,"namespace":"default"}}`, - }, - { - name: "errors if the release does not exist", - existingReleases: []*release.Release{ - createRelease("apache", releaseName, "default", 1, release.StatusSuperseded), - createRelease("apache", releaseName, "default", 2, release.StatusDeployed), - }, - queryString: "action=rollback&revision=1", - params: map[string]string{nameParam: "does-not-exist"}, - statusCode: http.StatusNotFound, - expectedReleases: []*release.Release{ - createRelease("apache", releaseName, "default", 1, release.StatusSuperseded), - createRelease("apache", releaseName, "default", 2, release.StatusDeployed), - }, - responseBody: `{"code":404,"message":"release: not found"}`, - }, - { - name: "errors if the revision does not exist", - existingReleases: []*release.Release{ - createRelease("apache", releaseName, "default", 1, release.StatusSuperseded), - createRelease("apache", releaseName, "default", 2, release.StatusDeployed), - }, - queryString: "action=rollback&revision=3", - params: map[string]string{nameParam: "apache"}, - statusCode: http.StatusNotFound, - expectedReleases: []*release.Release{ - createRelease("apache", releaseName, "default", 1, release.StatusSuperseded), - createRelease("apache", releaseName, "default", 2, release.StatusDeployed), - }, - responseBody: `{"code":404,"message":"release: not found"}`, - }, - { - name: "errors if the revision is not specified", - existingReleases: []*release.Release{ - createRelease("apache", releaseName, "default", 1, release.StatusSuperseded), - createRelease("apache", releaseName, "default", 2, release.StatusDeployed), - }, - queryString: "action=rollback", - params: map[string]string{nameParam: "apache"}, - statusCode: http.StatusUnprocessableEntity, - expectedReleases: []*release.Release{ - createRelease("apache", releaseName, "default", 1, release.StatusSuperseded), - createRelease("apache", releaseName, "default", 2, release.StatusDeployed), - }, - responseBody: `{"code":422,"message":"Missing revision to rollback in request"}`, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - k := &kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: ioutil.Discard}} - cfg := newConfigFixture(t, k) - createExistingReleases(t, cfg, tc.existingReleases) - req := httptest.NewRequest("PUT", fmt.Sprintf("https://example.com/whatever?%s", tc.queryString), strings.NewReader("")) - response := httptest.NewRecorder() - - OperateRelease(*cfg, response, req, tc.params) - - if got, want := response.Code, tc.statusCode; got != want { - t.Errorf("got: %d, want: %d", got, want) - } - - // using a fixed date to avoid time-dependant tests - // ideally, we should mutate the date as we are doing for deleted releases - fixedDateResponse := lastDeployedRegex.ReplaceAllString(response.Body.String(), `"last_deployed":"1977-09-02T22:04:05Z"`) - if got, want := fixedDateResponse, tc.responseBody; got != want { - t.Errorf("got: %q, want: %q", got, want) - } - - actualReleases, err := cfg.ActionConfig.Releases.ListReleases() - if err != nil { - t.Fatalf("%+v", err) - } - - if got, want := actualReleases, tc.expectedReleases; !cmp.Equal(want, got, releaseComparer) { - t.Errorf("mismatch (-want +got):\n%s", cmp.Diff(want, got, releaseComparer)) - } - }) - } -} - -func TestUpgradeAction(t *testing.T) { - const releaseName = "my-release" - testCases := []struct { - name string - existingReleases []*release.Release - queryString string - requestBody string - params map[string]string - statusCode int - expectedReleases []*release.Release - responseBody string - }{ - { - name: "upgrade a release", - existingReleases: []*release.Release{ - createRelease("apache", releaseName, "default", 1, release.StatusDeployed), - }, - queryString: "action=upgrade", - requestBody: `{"chartName": "apache", "releaseName":"my-release", "version": "1.0.0", "appRepositoryResourceName": "bitnami", "appRepositoryResourceNamespace": "default"}`, - params: map[string]string{nameParam: releaseName}, - statusCode: http.StatusOK, - expectedReleases: []*release.Release{ - createRelease("apache", releaseName, "default", 1, release.StatusSuperseded), - createRelease("apache", releaseName, "default", 2, release.StatusDeployed), - }, - responseBody: `{"data":{"name":"my-release","info":{"first_deployed":"1977-09-02T22:04:05Z","last_deployed":"1977-09-02T22:04:05Z","deleted":"","description":"Upgrade complete","status":"deployed"},"chart":{"metadata":{"name":"apache","version":"1.0.0"},"lock":null,"templates":null,"values":{},"schema":null,"files":null},"version":2,"namespace":"default"}}`, - }, - { - name: "upgrade a missing release", - existingReleases: []*release.Release{}, - queryString: "action=upgrade", - requestBody: `{"chartName": "apache", "releaseName":"my-release", "version": "1.0.0", "appRepositoryResourceName": "bitnami", "appRepositoryResourceNamespace": "default"}`, - params: map[string]string{nameParam: releaseName}, - statusCode: http.StatusNotFound, - // expectedReleases is `nil` because nil slice != empty slice - // sotrage.ListReleases() returns a nil slice if no releases are found - expectedReleases: nil, - responseBody: `{"code":404,"message":"release: not found"}`, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - k := &kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: ioutil.Discard}} - cfg := newConfigFixture(t, k) - createExistingReleases(t, cfg, tc.existingReleases) - req := httptest.NewRequest("PUT", fmt.Sprintf("https://example.com/whatever?%s", tc.queryString), strings.NewReader(tc.requestBody)) - response := httptest.NewRecorder() - - OperateRelease(*cfg, response, req, tc.params) - - if got, want := response.Code, tc.statusCode; got != want { - t.Errorf("got: %d, want: %d", got, want) - } - - // using a fixed date to avoid time-dependant tests - // ideally, we should mutate the date as we are doing for deleted releases - fixedDateResponse := lastDeployedRegex.ReplaceAllString(response.Body.String(), `"last_deployed":"1977-09-02T22:04:05Z"`) - if got, want := fixedDateResponse, tc.responseBody; got != want { - t.Errorf("got: %q, want: %q", got, want) - } - - actualReleases, err := cfg.ActionConfig.Releases.ListReleases() - if err != nil { - t.Fatalf("%+v", err) - } - - if got, want := actualReleases, tc.expectedReleases; !cmp.Equal(want, got, releaseComparer) { - t.Errorf("mismatch (-want +got):\n%s", cmp.Diff(want, got, releaseComparer)) - } - }) - } -} diff --git a/cmd/kubeops/internal/httphandler/http-handler.go b/cmd/kubeops/internal/httphandler/http-handler.go index b2dc3173a47..ae7250b1bcb 100644 --- a/cmd/kubeops/internal/httphandler/http-handler.go +++ b/cmd/kubeops/internal/httphandler/http-handler.go @@ -11,8 +11,6 @@ import ( "strings" "github.com/gorilla/mux" - "github.com/vmware-tanzu/kubeapps/cmd/apprepository-controller/pkg/apis/apprepository/v1alpha1" - "github.com/vmware-tanzu/kubeapps/cmd/kubeops/internal/auth" "github.com/vmware-tanzu/kubeapps/pkg/kube" corev1 "k8s.io/api/core/v1" k8sErrors "k8s.io/apimachinery/pkg/api/errors" @@ -25,19 +23,20 @@ type namespacesResponse struct { Namespaces []corev1.Namespace `json:"namespaces"` } -// appRepositoryResponse is used to marshal the JSON response -type appRepositoryResponse struct { - AppRepository v1alpha1.AppRepository `json:"appRepository"` - Secret corev1.Secret `json:"secret"` +type allowedResponse struct { + Allowed bool `json:"allowed"` } -// appRepositoryListResponse is used to marshal the JSON response -type appRepositoryListResponse struct { - AppRepositoryList v1alpha1.AppRepositoryList `json:"appRepository"` -} +// tokenPrefix is the string preceding the token in the Authorization header. +const tokenPrefix = "Bearer " -type allowedResponse struct { - Allowed bool `json:"allowed"` +// ExtractToken extracts the token from a correctly formatted Authorization header. +func extractToken(headerValue string) string { + if strings.HasPrefix(headerValue, tokenPrefix) { + return headerValue[len(tokenPrefix):] + } else { + return "" + } } // JSONError returns an error code and a JSON response @@ -97,244 +96,10 @@ func getHeaderNamespaces(req *http.Request, headerName, headerPattern string) ([ return namespaces, nil } -// ListAppRepositories list app repositories -func ListAppRepositories(handler kube.AuthHandler) func(w http.ResponseWriter, req *http.Request) { - return func(w http.ResponseWriter, req *http.Request) { - requestNamespace, requestCluster := getNamespaceAndCluster(req) - token := auth.ExtractToken(req.Header.Get("Authorization")) - - clientset, err := handler.AsUser(token, requestCluster) - if err != nil { - returnK8sError(err, "list", "AppRepositories", w) - return - } - - appRepos, err := clientset.ListAppRepositories(requestNamespace) - if err != nil { - returnK8sError(err, "list", "AppRepositories", w) - return - } - response := appRepositoryListResponse{ - AppRepositoryList: *appRepos, - } - responseBody, err := json.Marshal(response) - if err != nil { - JSONError(w, err.Error(), http.StatusInternalServerError) - return - } - _, err = w.Write(responseBody) - if err != nil { - return - } - } -} - -// CreateAppRepository creates App Repository -func CreateAppRepository(handler kube.AuthHandler) func(w http.ResponseWriter, req *http.Request) { - return func(w http.ResponseWriter, req *http.Request) { - requestNamespace, requestCluster := getNamespaceAndCluster(req) - token := auth.ExtractToken(req.Header.Get("Authorization")) - - clientset, err := handler.AsUser(token, requestCluster) - if err != nil { - returnK8sError(err, "create", "AppRepository", w) - return - } - - appRepo, err := clientset.CreateAppRepository(req.Body, requestNamespace) - if err != nil { - returnK8sError(err, "create", "AppRepository", w) - return - } - w.WriteHeader(http.StatusCreated) - response := appRepositoryResponse{ - AppRepository: *appRepo, - } - responseBody, err := json.Marshal(response) - if err != nil { - JSONError(w, err.Error(), http.StatusInternalServerError) - return - } - _, err = w.Write(responseBody) - if err != nil { - return - } - } -} - -// UpdateAppRepository updates an App Repository -func UpdateAppRepository(handler kube.AuthHandler) func(w http.ResponseWriter, req *http.Request) { - return func(w http.ResponseWriter, req *http.Request) { - requestNamespace, requestCluster := getNamespaceAndCluster(req) - token := auth.ExtractToken(req.Header.Get("Authorization")) - - clientset, err := handler.AsUser(token, requestCluster) - if err != nil { - returnK8sError(err, "update", "AppRepository", w) - return - } - - appRepo, err := clientset.UpdateAppRepository(req.Body, requestNamespace) - if err != nil { - returnK8sError(err, "update", "AppRepository", w) - return - } - w.WriteHeader(http.StatusOK) - response := appRepositoryResponse{ - AppRepository: *appRepo, - } - responseBody, err := json.Marshal(response) - if err != nil { - JSONError(w, err.Error(), http.StatusInternalServerError) - return - } - _, err = w.Write(responseBody) - if err != nil { - return - } - } -} - -// RefreshAppRepository forces a refresh in a given apprepository (by updating resyncRequests property) -func RefreshAppRepository(handler kube.AuthHandler) func(w http.ResponseWriter, req *http.Request) { - return func(w http.ResponseWriter, req *http.Request) { - requestNamespace, requestCluster := getNamespaceAndCluster(req) - repoName := mux.Vars(req)["name"] - token := auth.ExtractToken(req.Header.Get("Authorization")) - - clientset, err := handler.AsUser(token, requestCluster) - if err != nil { - returnK8sError(err, "refresh", "AppRepository", w) - return - } - - appRepo, err := clientset.RefreshAppRepository(repoName, requestNamespace) - if err != nil { - returnK8sError(err, "refresh", "AppRepository", w) - return - } - w.WriteHeader(http.StatusOK) - response := appRepositoryResponse{ - AppRepository: *appRepo, - } - responseBody, err := json.Marshal(response) - if err != nil { - JSONError(w, err.Error(), http.StatusInternalServerError) - return - } - _, err = w.Write(responseBody) - if err != nil { - return - } - } -} - -// ValidateAppRepository returns a 200 if the connection to the AppRepo can be established -func ValidateAppRepository(handler kube.AuthHandler) func(w http.ResponseWriter, req *http.Request) { - return func(w http.ResponseWriter, req *http.Request) { - requestNamespace, requestCluster := getNamespaceAndCluster(req) - token := auth.ExtractToken(req.Header.Get("Authorization")) - - clientset, err := handler.AsUser(token, requestCluster) - if err != nil { - returnK8sError(err, "validate", "AppRepository", w) - return - } - - res, err := clientset.ValidateAppRepository(req.Body, requestNamespace) - if err != nil { - returnK8sError(err, "validate", "AppRepository", w) - return - } - responseBody, err := json.Marshal(res) - if err != nil { - JSONError(w, err.Error(), http.StatusInternalServerError) - return - } - _, err = w.Write(responseBody) - if err != nil { - return - } - } -} - -// DeleteAppRepository deletes an App Repository -func DeleteAppRepository(kubeHandler kube.AuthHandler) func(w http.ResponseWriter, req *http.Request) { - return func(w http.ResponseWriter, req *http.Request) { - requestNamespace, requestCluster := getNamespaceAndCluster(req) - repoName := mux.Vars(req)["name"] - token := auth.ExtractToken(req.Header.Get("Authorization")) - - clientset, err := kubeHandler.AsUser(token, requestCluster) - if err != nil { - returnK8sError(err, "delete", "AppRepository", w) - return - } - - err = clientset.DeleteAppRepository(repoName, requestNamespace) - if err != nil { - returnK8sError(err, "delete", "AppRepository", w) - } - } -} - -// GetAppRepository gets an App Repository with a related secret if present. -func GetAppRepository(kubeHandler kube.AuthHandler) func(w http.ResponseWriter, req *http.Request) { - return func(w http.ResponseWriter, req *http.Request) { - requestNamespace, requestCluster := getNamespaceAndCluster(req) - repoName := mux.Vars(req)["name"] - token := auth.ExtractToken(req.Header.Get("Authorization")) - - clientset, err := kubeHandler.AsUser(token, requestCluster) - if err != nil { - returnK8sError(err, "get", "AppRepository", w) - return - } - - appRepo, err := clientset.GetAppRepository(repoName, requestNamespace) - if err != nil { - returnK8sError(err, "get", "AppRepository", w) - return - } - - response := appRepositoryResponse{ - AppRepository: *appRepo, - } - - auth := &appRepo.Spec.Auth - if auth != nil { - var secretSelector *corev1.SecretKeySelector - if auth.CustomCA != nil { - secretSelector = &auth.CustomCA.SecretKeyRef - } else if auth.Header != nil { - secretSelector = &auth.Header.SecretKeyRef - } - if secretSelector != nil { - secret, err := clientset.GetSecret(secretSelector.Name, requestNamespace) - if err != nil { - returnK8sError(err, "get", "Secret", w) - return - } - response.Secret = *secret - } - } - - responseBody, err := json.Marshal(response) - if err != nil { - JSONError(w, err.Error(), http.StatusInternalServerError) - return - } - _, err = w.Write(responseBody) - if err != nil { - return - } - } -} - // GetNamespaces return the list of namespaces func GetNamespaces(kubeHandler kube.AuthHandler) func(w http.ResponseWriter, req *http.Request) { return func(w http.ResponseWriter, req *http.Request) { - token := auth.ExtractToken(req.Header.Get("Authorization")) + token := extractToken(req.Header.Get("Authorization")) _, requestCluster := getNamespaceAndCluster(req) options := kubeHandler.GetOptions() @@ -402,7 +167,7 @@ func GetOperatorLogo(kubeHandler kube.AuthHandler) func(w http.ResponseWriter, r // CanI returns a boolean if the user can do the given action func CanI(kubeHandler kube.AuthHandler) func(w http.ResponseWriter, req *http.Request) { return func(w http.ResponseWriter, req *http.Request) { - token := auth.ExtractToken(req.Header.Get("Authorization")) + token := extractToken(req.Header.Get("Authorization")) _, requestCluster := getNamespaceAndCluster(req) clientset, err := kubeHandler.AsUser(token, requestCluster) @@ -448,14 +213,6 @@ func SetupDefaultRoutes(r *mux.Router, namespaceHeaderName, namespaceHeaderPatte //TODO(agamez): move these endpoints to a separate plugin when possible r.Methods("POST").Path("/clusters/{cluster}/can-i").Handler(http.HandlerFunc(CanI(backendHandler))) r.Methods("GET").Path("/clusters/{cluster}/namespaces").Handler(http.HandlerFunc(GetNamespaces(backendHandler))) - r.Methods("GET").Path("/clusters/{cluster}/apprepositories").Handler(http.HandlerFunc(ListAppRepositories(backendHandler))) - r.Methods("GET").Path("/clusters/{cluster}/namespaces/{namespace}/apprepositories").Handler(http.HandlerFunc(ListAppRepositories(backendHandler))) - r.Methods("POST").Path("/clusters/{cluster}/namespaces/{namespace}/apprepositories").Handler(http.HandlerFunc(CreateAppRepository(backendHandler))) - r.Methods("POST").Path("/clusters/{cluster}/namespaces/{namespace}/apprepositories/validate").Handler(http.HandlerFunc(ValidateAppRepository(backendHandler))) - r.Methods("GET").Path("/clusters/{cluster}/namespaces/{namespace}/apprepositories/{name}").Handler(http.HandlerFunc(GetAppRepository(backendHandler))) - r.Methods("PUT").Path("/clusters/{cluster}/namespaces/{namespace}/apprepositories/{name}").Handler(http.HandlerFunc(UpdateAppRepository(backendHandler))) - r.Methods("POST").Path("/clusters/{cluster}/namespaces/{namespace}/apprepositories/{name}/refresh").Handler(http.HandlerFunc(RefreshAppRepository(backendHandler))) - r.Methods("DELETE").Path("/clusters/{cluster}/namespaces/{namespace}/apprepositories/{name}").Handler(http.HandlerFunc(DeleteAppRepository(backendHandler))) r.Methods("GET").Path("/clusters/{cluster}/namespaces/{namespace}/operator/{name}/logo").Handler(http.HandlerFunc(GetOperatorLogo(backendHandler))) return nil } diff --git a/cmd/kubeops/internal/httphandler/http-handler_test.go b/cmd/kubeops/internal/httphandler/http-handler_test.go index c8f74c4c35c..dbb104ab1f1 100644 --- a/cmd/kubeops/internal/httphandler/http-handler_test.go +++ b/cmd/kubeops/internal/httphandler/http-handler_test.go @@ -7,7 +7,6 @@ import ( "bytes" "encoding/json" "fmt" - "io/ioutil" "net/http" "net/http/httptest" "strings" @@ -20,21 +19,8 @@ import ( k8sErrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" - - v1alpha1 "github.com/vmware-tanzu/kubeapps/cmd/apprepository-controller/pkg/apis/apprepository/v1alpha1" ) -func checkAppResponse(t *testing.T, response *httptest.ResponseRecorder, expectedResponse appRepositoryResponse) { - var appRepoResponse appRepositoryResponse - err := json.NewDecoder(response.Body).Decode(&appRepoResponse) - if err != nil { - t.Fatalf("%+v", err) - } - if got, want := appRepoResponse, expectedResponse; !cmp.Equal(want, got) { - t.Errorf("mismatch (-want +got):\n%s", cmp.Diff(want, got)) - } -} - func checkError(t *testing.T, response *httptest.ResponseRecorder, expectedError error) { if response.Code == 500 { // If the error is a 500 we simply retunr a string (encoded in JSON) @@ -59,274 +45,27 @@ func checkError(t *testing.T, response *httptest.ResponseRecorder, expectedError } } -func TestListAppRepositories(t *testing.T) { - testCases := []struct { - name string - appRepos []*v1alpha1.AppRepository - err error - expectedCode int - }{ - { - name: "it should return the list of repos", - expectedCode: 200, - }, - { - name: "it should return an error", - err: fmt.Errorf("boom"), - expectedCode: 500, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - listFunc := ListAppRepositories(&kube.FakeHandler{AppRepos: []*v1alpha1.AppRepository{ - {ObjectMeta: metav1.ObjectMeta{Name: "foo"}}, - }, Err: tc.err}) - req := httptest.NewRequest("GET", "https://foo.bar/backend/v1/namespaces/kubeapps/apprepositories", strings.NewReader("data")) - req = mux.SetURLVars(req, map[string]string{"namespace": "kubeapps"}) - - response := httptest.NewRecorder() - listFunc(response, req) - - if got, want := response.Code, tc.expectedCode; got != want { - t.Errorf("got: %d, want: %d\nBody: %s", got, want, response.Body) - } - - if response.Code != 200 { - checkError(t, response, tc.err) - } - }) - } -} - -func TestGetAppRepository(t *testing.T) { - testCases := []struct { - name string - appRepo *v1alpha1.AppRepository - secret *corev1.Secret - err error - expectedCode int - }{ - { - name: "it should return a 200 if the repo is found", - appRepo: &v1alpha1.AppRepository{ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "kubeapps"}}, - expectedCode: 200, - }, - { - name: "it should return a corresponding secret if present", - appRepo: &v1alpha1.AppRepository{ - ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "kubeapps"}, - Spec: v1alpha1.AppRepositorySpec{ - Auth: v1alpha1.AppRepositoryAuth{ - Header: &v1alpha1.AppRepositoryAuthHeader{ - SecretKeyRef: corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "repo-secret", - }, - Key: "authorizationHeader", - }, - }, - }, - }, - }, - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "repo-secret", Namespace: "kubeapps"}, - StringData: map[string]string{ - "authorizationHeader": "someheader", - }, - }, - expectedCode: 200, - }, - { - name: "it should return a 404 if app repository not found", - appRepo: &v1alpha1.AppRepository{ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "kubeapps"}}, - err: k8sErrors.NewNotFound(schema.GroupResource{}, "foo"), - expectedCode: 404, - }, - { - name: "it should return a 404 if related secret is not found", - appRepo: &v1alpha1.AppRepository{ - ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "kubeapps"}, - Spec: v1alpha1.AppRepositorySpec{ - Auth: v1alpha1.AppRepositoryAuth{ - Header: &v1alpha1.AppRepositoryAuthHeader{ - SecretKeyRef: corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "repo-secret", - }, - Key: "authorizationHeader", - }, - }, - }, - }, - }, - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "another-repo-secret", Namespace: "kubeapps"}, - StringData: map[string]string{ - "authorizationHeader": "someheader", - }, - }, - expectedCode: 404, - }, - { - name: "it should return a 403 when forbidden", - appRepo: &v1alpha1.AppRepository{ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "kubeapps"}}, - err: k8sErrors.NewForbidden(schema.GroupResource{}, "foo", fmt.Errorf("nope")), - expectedCode: 403, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - getAppFunc := GetAppRepository(&kube.FakeHandler{ - AppRepos: []*v1alpha1.AppRepository{tc.appRepo}, - Secrets: []*corev1.Secret{tc.secret}, - Err: tc.err, - }) - req := httptest.NewRequest("GET", "https://foo.bar/backend/v1/namespaces/kubeapps/apprepositories/foo", strings.NewReader("")) - req = mux.SetURLVars(req, map[string]string{"namespace": "kubeapps", "name": "foo"}) - - response := httptest.NewRecorder() - getAppFunc(response, req) - - if got, want := response.Code, tc.expectedCode; got != want { - t.Errorf("got: %d, want: %d\nBody: %s", got, want, response.Body) - } - expectedResponse := appRepositoryResponse{AppRepository: *tc.appRepo} - if tc.secret != nil { - expectedResponse.Secret = *tc.secret - } - if response.Code == 200 { - checkAppResponse(t, response, expectedResponse) - } - }) - } -} -func TestCreateAppRepository(t *testing.T) { - testCases := []struct { - name string - appRepo *v1alpha1.AppRepository - err error - expectedCode int - }{ - { - name: "it should return the repo and a 200 if the repo is created", - appRepo: &v1alpha1.AppRepository{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}, - expectedCode: 201, - }, - { - name: "it should return a 404 if not found", - err: k8sErrors.NewNotFound(schema.GroupResource{}, "foo"), - expectedCode: 404, - }, - { - name: "it should return a 409 when conflict", - err: k8sErrors.NewConflict(schema.GroupResource{}, "foo", fmt.Errorf("already exists")), - expectedCode: 409, - }, - { - name: "it returns a json 500 error as a plain string for internal backend errors", - err: fmt.Errorf("bang"), - expectedCode: 500, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - createAppFunc := CreateAppRepository(&kube.FakeHandler{CreatedRepo: tc.appRepo, Err: tc.err}) - req := httptest.NewRequest("POST", "https://foo.bar/backend/v1/namespaces/kubeapps/apprepositories", strings.NewReader("data")) - req = mux.SetURLVars(req, map[string]string{"namespace": "kubeapps"}) - - response := httptest.NewRecorder() - createAppFunc(response, req) - - if got, want := response.Code, tc.expectedCode; got != want { - t.Errorf("got: %d, want: %d\nBody: %s", got, want, response.Body) - } - - if response.Code == 201 { - checkAppResponse(t, response, appRepositoryResponse{AppRepository: *tc.appRepo}) - } else { - checkError(t, response, tc.err) - } - }) - } -} - -func TestUpdateAppRepository(t *testing.T) { - testCases := []struct { - name string - appRepo *v1alpha1.AppRepository - err error - expectedCode int - }{ - { - name: "it should return the repo and a 200 if the repo is updated", - appRepo: &v1alpha1.AppRepository{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}, - expectedCode: 200, - }, - { - name: "it should return a 404 if not found", - err: k8sErrors.NewNotFound(schema.GroupResource{}, "foo"), - expectedCode: 404, - }, - { - name: "it returns a json 500 error as a plain string for internal backend errors", - err: fmt.Errorf("bang"), - expectedCode: 500, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - createAppFunc := UpdateAppRepository(&kube.FakeHandler{UpdatedRepo: tc.appRepo, Err: tc.err}) - req := httptest.NewRequest("POST", "https://foo.bar/backend/v1/namespaces/kubeapps/apprepositories/foo", strings.NewReader("data")) - req = mux.SetURLVars(req, map[string]string{"namespace": "kubeapps"}) - - response := httptest.NewRecorder() - createAppFunc(response, req) - - if got, want := response.Code, tc.expectedCode; got != want { - t.Errorf("got: %d, want: %d\nBody: %s", got, want, response.Body) - } - - if response.Code == 200 { - checkAppResponse(t, response, appRepositoryResponse{AppRepository: *tc.appRepo}) - } else { - checkError(t, response, tc.err) - } - }) - } -} - -func TestDeleteAppRepository(t *testing.T) { - testCases := []struct { - name string - err error - expectedCode int +func TestExtractToken(t *testing.T) { + testSuite := []struct { + Name string + TokenRaw string + ExpectedToken string }{ { - name: "it should return a 200 if the repo is deleted", - expectedCode: 200, + "Token ok", + "Bearer foo", + "foo", }, { - name: "it should return a 404 if not found", - err: k8sErrors.NewNotFound(schema.GroupResource{}, "foo"), - expectedCode: 404, - }, - { - name: "it should return a 403 when forbidden", - err: k8sErrors.NewForbidden(schema.GroupResource{}, "foo", fmt.Errorf("nope")), - expectedCode: 403, + "Token nok", + "foo bar", + "", }, } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - deleteAppFunc := DeleteAppRepository(&kube.FakeHandler{Err: tc.err}) - req := httptest.NewRequest("POST", "https://foo.bar/backend/v1/namespaces/kubeapps/apprepositories", strings.NewReader("data")) - req = mux.SetURLVars(req, map[string]string{"namespace": "kubeapps"}) - - response := httptest.NewRecorder() - deleteAppFunc(response, req) - - if got, want := response.Code, tc.expectedCode; got != want { - t.Errorf("got: %d, want: %d\nBody: %s", got, want, response.Body) + for _, test := range testSuite { + t.Run(test.Name, func(t *testing.T) { + if got, want := extractToken(test.TokenRaw), test.ExpectedToken; !cmp.Equal(want, got) { + t.Errorf("mismatch (-want +got):\n%s", cmp.Diff(want, got)) } }) } @@ -457,54 +196,6 @@ func TestGetNamespaces(t *testing.T) { } } -func TestValidateAppRepository(t *testing.T) { - testCases := []struct { - name string - err error - validationResponse kube.ValidationResponse - expectedCode int - expectedBody string - }{ - { - name: "it should return OK if no error is detected", - validationResponse: kube.ValidationResponse{Code: 200, Message: "OK"}, - expectedCode: 200, - expectedBody: `{"code":200,"message":"OK"}`, - }, - { - name: "it should return the error code if given", - err: fmt.Errorf("Boom"), - validationResponse: kube.ValidationResponse{}, - expectedCode: 500, - expectedBody: "\"Boom\"\n", - }, - { - name: "it should return an error in the validation response", - validationResponse: kube.ValidationResponse{Code: 401, Message: "Forbidden"}, - expectedCode: 200, - expectedBody: `{"code":401,"message":"Forbidden"}`, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - validateAppRepoFunc := ValidateAppRepository(&kube.FakeHandler{ValRes: &tc.validationResponse, Err: tc.err}) - req := httptest.NewRequest("POST", "https://foo.bar/backend/v1/namespaces/kubeapps/apprepositories/validate", strings.NewReader("data")) - - response := httptest.NewRecorder() - validateAppRepoFunc(response, req) - - if got, want := response.Code, tc.expectedCode; got != want { - t.Errorf("got: %d, want: %d\nBody: %s", got, want, response.Body) - } - - responseBody, _ := ioutil.ReadAll(response.Body) - if got, want := string(responseBody), tc.expectedBody; got != want { - t.Errorf("got: %s, want: %s\n", got, want) - } - }) - } -} - func TestGetOperatorLogo(t *testing.T) { testCases := []struct { name string diff --git a/cmd/kubeops/internal/response/response.go b/cmd/kubeops/internal/response/response.go deleted file mode 100644 index dcd0aee03a4..00000000000 --- a/cmd/kubeops/internal/response/response.go +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright 2017-2022 the Kubeapps contributors. -// SPDX-License-Identifier: Apache-2.0 - -// Package response implements helpers for JSON responses. -package response - -import ( - "encoding/json" - "net/http" -) - -/* -ErrorResponse describes a JSON error response with the following body: - { - "code": 404, - "message": "not found" - } -*/ -type ErrorResponse struct { - Code int `json:"code"` - Message string `json:"message"` -} - -// NewErrorResponse returns a new ErrorResponse -func NewErrorResponse(code int, message string) ErrorResponse { - return ErrorResponse{code, message} -} - -func (e ErrorResponse) Write(w http.ResponseWriter) { - responseBody, err := json.Marshal(e) - if err != nil { - return - } - w.WriteHeader(e.Code) - _, err = w.Write(responseBody) - if err != nil { - return - } -} - -/* -DataResponse describes a JSON response containing resource data: - { - data: {...} - } -If resource data is an array of objects, the data key will be an array: - { - data: [...] - } -*/ -type DataResponse struct { - Code int `json:"-"` - Data interface{} `json:"data"` - Meta interface{} `json:"meta,omitempty"` -} - -// NewDataResponse returns a new DataResponse -func NewDataResponse(resources interface{}) DataResponse { - return DataResponse{http.StatusOK, resources, nil} -} - -func (d DataResponse) Write(w http.ResponseWriter) { - responseBody, err := json.Marshal(d) - if err != nil { - return - } - w.WriteHeader(d.Code) - _, err = w.Write(responseBody) - if err != nil { - return - } -} diff --git a/cmd/kubeops/internal/response/response_test.go b/cmd/kubeops/internal/response/response_test.go deleted file mode 100644 index 5882cbf8f98..00000000000 --- a/cmd/kubeops/internal/response/response_test.go +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright 2017-2022 the Kubeapps contributors. -// SPDX-License-Identifier: Apache-2.0 - -package response - -import ( - "encoding/json" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" -) - -type resource struct { - ID string `json:"id"` -} - -func TestNewErrorResponse(t *testing.T) { - type args struct { - code int - message string - } - tests := []struct { - name string - args args - want ErrorResponse - }{ - {"404 response", args{http.StatusNotFound, "not found"}, ErrorResponse{http.StatusNotFound, "not found"}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, NewErrorResponse(tt.args.code, tt.args.message)) - }) - } -} - -func TestErrorResponse_Write(t *testing.T) { - tests := []struct { - name string - e ErrorResponse - }{ - {"404 response", ErrorResponse{http.StatusNotFound, "not found"}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - w := httptest.NewRecorder() - tt.e.Write(w) - assert.Equal(t, tt.e.Code, w.Code) - var body ErrorResponse - json.NewDecoder(w.Body).Decode(&body) - assert.Equal(t, tt.e, body) - }) - } -} - -func TestNewDataResponse(t *testing.T) { - tests := []struct { - name string - data interface{} - }{ - {"single resource", resource{"test"}}, - {"multiple resources", []resource{{"one"}, {"two"}}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - d := NewDataResponse(tt.data) - assert.Equal(t, tt.data, d.Data) - }) - } -} - -func TestDataResponse_Write(t *testing.T) { - tests := []struct { - name string - d DataResponse - want string - }{ - {"single resource", DataResponse{http.StatusOK, resource{"test"}, nil}, `{"data":{"id":"test"}}`}, - {"multiple resources", DataResponse{http.StatusOK, []resource{{"one"}, {"two"}}, nil}, `{"data":[{"id":"one"},{"id":"two"}]}`}, - {"multiple resources with meta", DataResponse{http.StatusOK, []resource{{"one"}, {"two"}}, resource{"foo"}}, `{"data":[{"id":"one"},{"id":"two"}],"meta":{"id":"foo"}}`}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - w := httptest.NewRecorder() - tt.d.Write(w) - assert.Equal(t, http.StatusOK, w.Code) - bytes, err := ioutil.ReadAll(w.Body) - assert.NoError(t, err) - body := string(bytes) - assert.Equal(t, tt.want, body) - }) - } -} diff --git a/cmd/kubeops/server/server.go b/cmd/kubeops/server/server.go index 7f742397af3..d9bf76f5d60 100644 --- a/cmd/kubeops/server/server.go +++ b/cmd/kubeops/server/server.go @@ -15,19 +15,13 @@ import ( "github.com/gorilla/mux" "github.com/heptiolabs/healthcheck" negroni "github.com/urfave/negroni/v2" - "github.com/vmware-tanzu/kubeapps/cmd/kubeops/internal/handler" "github.com/vmware-tanzu/kubeapps/cmd/kubeops/internal/httphandler" - "github.com/vmware-tanzu/kubeapps/pkg/agent" "github.com/vmware-tanzu/kubeapps/pkg/kube" log "k8s.io/klog/v2" ) type ServeOptions struct { - HelmDriverArg string - ListLimit int - UserAgentComment string - Timeout int64 ClustersConfigPath string PinnipedProxyURL string PinnipedProxyCACert string @@ -35,8 +29,6 @@ type ServeOptions struct { Qps float32 NamespaceHeaderName string NamespaceHeaderPattern string - UserAgent string - GlobalReposNamespace string } const clustersCAFilesPrefix = "/etc/additional-clusters-cafiles" @@ -62,27 +54,6 @@ func Serve(serveOpts ServeOptions) error { defer cleanupCAFiles() } - options := handler.Options{ - ListLimit: serveOpts.ListLimit, - Timeout: serveOpts.Timeout, - KubeappsNamespace: kubeappsNamespace, - ClustersConfig: clustersConfig, - Burst: serveOpts.Burst, - QPS: serveOpts.Qps, - NamespaceHeaderName: serveOpts.NamespaceHeaderName, - NamespaceHeaderPattern: serveOpts.NamespaceHeaderPattern, - UserAgent: serveOpts.UserAgent, - } - - storageForDriver := agent.StorageForSecrets - if serveOpts.HelmDriverArg != "" { - var err error - storageForDriver, err = agent.ParseDriverType(serveOpts.HelmDriverArg) - if err != nil { - panic(err) - } - } - withHandlerConfig := handler.WithHandlerConfig(storageForDriver, options) r := mux.NewRouter() // Healthcheck @@ -91,17 +62,6 @@ func Serve(serveOpts ServeOptions) error { r.Handle("/live", health) r.Handle("/ready", health) - // Routes - // Auth not necessary here with Helm 3 because it's done by Kubernetes. - addRoute := handler.AddRouteWith(r.PathPrefix("/v1").Subrouter(), withHandlerConfig) - addRoute("GET", "/clusters/{cluster}/releases", handler.ListAllReleases) - addRoute("GET", "/clusters/{cluster}/namespaces/{namespace}/releases", handler.ListReleases) - addRoute("POST", "/clusters/{cluster}/namespaces/{namespace}/releases", handler.CreateRelease) - addRoute("GET", "/clusters/{cluster}/namespaces/{namespace}/releases/{releaseName}", handler.GetRelease) - addRoute("PUT", "/clusters/{cluster}/namespaces/{namespace}/releases/{releaseName}", handler.OperateRelease) - addRoute("DELETE", "/clusters/{cluster}/namespaces/{namespace}/releases/{releaseName}", handler.DeleteRelease) - - // Backend routes unrelated to kubeops functionality. err := httphandler.SetupDefaultRoutes(r.PathPrefix("/backend/v1").Subrouter(), serveOpts.NamespaceHeaderName, serveOpts.NamespaceHeaderPattern, serveOpts.Burst, serveOpts.Qps, clustersConfig) if err != nil { return fmt.Errorf("Unable to setup backend routes: %+v", err) diff --git a/dashboard/public/openapi.yaml b/dashboard/public/openapi.yaml index 7372efd9a9a..d39d92a0a24 100644 --- a/dashboard/public/openapi.yaml +++ b/dashboard/public/openapi.yaml @@ -143,580 +143,13 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - "/api/v1/clusters/{cluster}/apprepositories": - get: - tags: - - kubeops - summary: "ListAppRepositories" - description: "" - operationId: "ListAppRepositories" - parameters: - - name: cluster - in: path - required: true - schema: - type: string - default: default - description: Cluster name - responses: - default: - description: Default code/message response - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "200": - description: Successful response - content: - text/plain: - schema: - $ref: "#/components/schemas/AppRepositoryResponse" - "401": - description: Unauthorized - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "/api/v1/clusters/{cluster}/namespaces/{namespace}/apprepositories": - get: - tags: - - kubeops - summary: "ListAppRepositories_ns" - description: "" - operationId: "ListAppRepositories_ns" - parameters: - - name: cluster - in: path - required: true - schema: - type: string - default: default - description: Cluster name - - name: namespace - in: path - required: true - schema: - type: string - default: default - description: Namespace name - responses: - default: - description: Default code/message response - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "200": - description: Successful response - content: - text/plain: - schema: - $ref: "#/components/schemas/AppRepositoryResponse" - "401": - description: Unauthorized - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - post: - tags: - - kubeops - summary: "CreateAppRepository" - description: "" - operationId: "CreateAppRepository" - parameters: - - name: cluster - in: path - required: true - schema: - type: string - default: default - description: Cluster name - - name: namespace - in: path - required: true - schema: - type: string - default: default - description: Namespace name - requestBody: - description: "" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AppRepositoryRequest" - responses: - default: - description: Default code/message response - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "200": - description: Successful response - content: - text/plain: - schema: - $ref: "#/components/schemas/AppRepositoryResponse" - "401": - description: Unauthorized - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "422": - description: Unprocessable entity - "/api/v1/clusters/{cluster}/namespaces/{namespace}/apprepositories/validate": - post: - tags: - - kubeops - summary: "ValidateAppRepository" - description: "" - operationId: "ValidateAppRepository" - parameters: - - name: cluster - in: path - required: true - schema: - type: string - default: default - description: Cluster name - - name: namespace - in: path - required: true - schema: - type: string - default: default - description: Namespace name - requestBody: - description: "" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AppRepository" - responses: - default: - description: Default code/message response - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "200": - description: Successful response - content: - text/plain: - schema: - $ref: "#/components/schemas/ValidationResponse" - "401": - description: Unauthorized - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "/api/v1/clusters/{cluster}/namespaces/{namespace}/apprepositories/{name}": - put: - tags: - - kubeops - summary: "UpdateAppRepository" - description: "" - operationId: "UpdateAppRepository" - parameters: - - name: cluster - in: path - required: true - schema: - type: string - default: default - description: Cluster name - - name: namespace - in: path - required: true - schema: - type: string - default: default - description: Namespace name - - name: name - in: path - required: true - schema: - type: string - default: default - description: Apprepository name - requestBody: - description: "" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AppRepositoryRequest" - responses: - default: - description: Default code/message response - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "200": - description: Successful response - content: - text/plain: - schema: - $ref: "#/components/schemas/AppRepositoryResponse" - "401": - description: Unauthorized - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - tags: - - kubeops - summary: "DeleteAppRepository" - description: "" - operationId: "DeleteAppRepository" - parameters: - - name: cluster - in: path - required: true - schema: - type: string - default: default - description: Cluster name - - name: namespace - in: path - required: true - schema: - type: string - default: default - description: Namespace name - - name: name - in: path - required: true - schema: - type: string - default: default - description: Apprepository name - responses: - "404": - description: Not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "401": - description: Unauthorized - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "500": - description: Internal server error - content: - text/plain: - schema: - type: string - "/api/v1/clusters/{cluster}/namespaces/{namespace}/apprepositories/{name}/refresh": - post: - tags: - - kubeops - summary: "RefreshAppRepository" - description: "" - operationId: "RefreshAppRepository" - parameters: - - name: cluster - in: path - required: true - schema: - type: string - default: default - description: Cluster name - - name: namespace - in: path - required: true - schema: - type: string - default: default - description: Namespace name - - name: name - in: path - required: true - schema: - type: string - default: default - description: Apprepository name - responses: - default: - description: Default code/message response - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "200": - description: Successful response - content: - text/plain: - schema: - $ref: "#/components/schemas/AppRepositoryResponse" - "401": - description: Unauthorized - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "/api/v1/clusters/{cluster}/namespaces/{namespace}/operator/{name}/logo": - get: - tags: - - kubeops - summary: "GetOperatorLogo" - description: "" - operationId: "GetOperatorLogo" - parameters: - - name: cluster - in: path - required: true - schema: - type: string - default: default - description: Cluster name - - name: namespace - in: path - required: true - schema: - type: string - default: default - description: Namespace name - - name: name - in: path - required: true - schema: - type: string - default: default - description: Apprepository name - responses: - default: - description: Default code/message response - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "200": - description: Successful response - content: - image/png: - schema: - type: string - format: binary - image/svg+xml: - schema: - type: string - "500": - description: Internal server error - content: - text/plain: - schema: - type: string - - # Endpoints defined at cmd/kubeops/main.go - "/api/kubeops/v1/clusters/{cluster}/releases": - get: - tags: - - kubeops - summary: "ListAllReleases" - description: "" - operationId: "ListAllReleases" - parameters: - - name: cluster - in: path - required: true - schema: - type: string - default: default - description: Cluster name - responses: - default: - description: Default code/message response - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "200": - description: Successful response - content: - application/json: - schema: - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/AppOverview" - "401": - description: Unauthorized - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "/api/kubeops/v1/clusters/{cluster}/namespaces/{namespace}/releases": - get: - tags: - - kubeops - summary: "ListReleases" - description: "" - operationId: "ListReleases" - parameters: - - name: cluster - in: path - required: true - schema: - type: string - default: default - description: Cluster name - - name: namespace - in: path - required: true - schema: - type: string - default: default - description: Namespace name - responses: - default: - description: Default code/message response - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "200": - description: Successful response - content: - application/json: - schema: - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/AppOverview" - "401": - description: Unauthorized - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - post: - tags: - - kubeops - summary: "CreateRelease" - description: "" - operationId: "CreateRelease" - parameters: - - name: cluster - in: path - required: true - schema: - type: string - default: default - description: Cluster name - - name: namespace - in: path - required: true - schema: - type: string - default: default - description: Namespace name - requestBody: - description: "" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/Details" - responses: - default: - description: Default code/message response - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "200": - description: Successful response - content: - application/json: - schema: - type: object - properties: - data: - $ref: "#/components/schemas/Release" - "401": - description: Unauthorized - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "500": - description: Internal server error - content: - text/plain: - schema: - type: string - "/api/kubeops/v1/clusters/{cluster}/namespaces/{namespace}/releases/{releaseName}": + "/api/v1/clusters/{cluster}/namespaces/{namespace}/operator/{name}/logo": get: tags: - kubeops - summary: "GetRelease" - description: "" - operationId: "GetRelease" - parameters: - - name: cluster - in: path - required: true - schema: - type: string - default: default - description: Cluster name - - name: namespace - in: path - required: true - schema: - type: string - default: default - description: Namespace name - - name: releaseName - in: path - required: true - schema: - type: string - default: default - description: Release name - responses: - default: - description: Default code/message response - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "200": - description: Successful response - content: - application/json: - schema: - type: object - properties: - data: - $ref: "#/components/schemas/Release" - "401": - description: Unauthorized - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - tags: - - kubeops - summary: "DeleteRelease" + summary: "GetOperatorLogo" description: "" - operationId: "DeleteRelease" + operationId: "GetOperatorLogo" parameters: - name: cluster in: path @@ -732,13 +165,13 @@ paths: type: string default: default description: Namespace name - - name: releaseName + - name: name in: path required: true schema: type: string default: default - description: Release name + description: Apprepository name responses: default: description: Default code/message response @@ -749,100 +182,19 @@ paths: "200": description: Successful response content: - text/plain: + image/png: schema: type: string - "401": - description: Unauthorized - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "500": - description: Internal server error - content: - text/plain: + format: binary + image/svg+xml: schema: type: string - put: - tags: - - kubeops - summary: "OperateRelease" - description: "" - operationId: "OperateRelease" - parameters: - - name: cluster - in: path - required: true - schema: - type: string - default: default - description: Cluster name - - name: namespace - in: path - required: true - schema: - type: string - default: default - description: Namespace name - - name: releaseName - in: path - required: true - schema: - type: string - default: default - description: Release name - - name: action - in: query - schema: - type: string - enum: ["upgrade", "rollback"] - default: upgrade - description: Action to perform - - name: revision - in: query - required: false - schema: - type: integer - format: int64 - default: 1 - description: Revision to rollback to (only if ?action=rollback) - requestBody: - description: "" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/Details" - responses: - default: - description: Default code/message response - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "200": - description: Successful response - content: - application/json: - schema: - type: object - properties: - data: - $ref: "#/components/schemas/Release" - "401": - description: Unauthorized - content: - application/json: - schema: - $ref: "#/components/schemas/Error" "500": description: Internal server error content: text/plain: schema: type: string - # Temporarily manually extracted from /cmd/kubeapps-apis/docs/kubeapps-apis.swagger.json /core/packages/v1alpha1/availablepackages: get: diff --git a/dashboard/src/actions/repos.test.tsx b/dashboard/src/actions/repos.test.tsx index 7d68ef248cc..6a1159f9697 100644 --- a/dashboard/src/actions/repos.test.tsx +++ b/dashboard/src/actions/repos.test.tsx @@ -5,27 +5,62 @@ import { AvailablePackageReference, InstalledPackageDetail, } from "gen/kubeappsapis/core/packages/v1alpha1/packages"; -import { PackageRepositoryAuth_PackageRepositoryAuthType } from "gen/kubeappsapis/core/packages/v1alpha1/repositories"; +import { + AddPackageRepositoryResponse, + DeletePackageRepositoryResponse, + GetPackageRepositoryDetailResponse, + GetPackageRepositorySummariesResponse, + PackageRepositoryAuth_PackageRepositoryAuthType, + PackageRepositoryDetail, + PackageRepositoryReference, + PackageRepositorySummary, + UpdatePackageRepositoryResponse, +} from "gen/kubeappsapis/core/packages/v1alpha1/repositories"; import { Plugin } from "gen/kubeappsapis/core/plugins/v1alpha1/plugins"; import context from "jest-plugin-context"; import configureMockStore from "redux-mock-store"; import thunk from "redux-thunk"; -import { AppRepository } from "shared/AppRepository"; +import { PackageRepositoriesService } from "shared/PackageRepositoriesService"; import PackagesService from "shared/PackagesService"; -import { - IAppRepository, - IPkgRepoFormData, - NotFoundError, - RepositoryStorageTypes, -} from "shared/types"; +import { IPkgRepoFormData, NotFoundError, RepositoryStorageTypes } from "shared/types"; import { getType } from "typesafe-actions"; import actions from "."; +import { convertPkgRepoDetailToSummary } from "./repos"; const { repos: repoActions } = actions; const mockStore = configureMockStore([thunk]); let store: any; -const appRepo = { spec: { resyncRequests: 10000 } }; +const plugin = { name: "my.plugin", version: "0.0.1" } as Plugin; +const packageRepoRef = { + identifier: "repo-abc", + context: { cluster: "default", namespace: "default" }, + plugin: plugin, +} as PackageRepositoryReference; + +const packageRepositorySummary = { + name: "repo-abc", + description: "", + namespaceScoped: false, + type: "helm", + url: "https://helm.repo", + packageRepoRef: packageRepoRef, +} as PackageRepositorySummary; + +const packageRepositoryDetail = { + name: "repo-abc", + type: "helm", + description: "", + interval: "10m", + namespaceScoped: false, + url: "https://helm.repo", + packageRepoRef: { + identifier: "repo-abc", + context: { cluster: "default", namespace: "default" }, + plugin: plugin, + }, +} as PackageRepositoryDetail; + const kubeappsNamespace = "kubeapps-namespace"; const globalReposNamespace = "kubeapps-repos-global"; @@ -41,16 +76,28 @@ beforeEach(() => { }, }, }); - AppRepository.list = jest.fn().mockImplementationOnce(() => { - return { items: { foo: "bar" } }; + PackageRepositoriesService.getPackageRepositorySummaries = jest + .fn() + .mockImplementationOnce(() => { + return { + packageRepositorySummaries: [packageRepositorySummary], + } as GetPackageRepositorySummariesResponse; + }); + PackageRepositoriesService.deletePackageRepository = jest.fn().mockImplementationOnce(() => { + return {} as DeletePackageRepositoryResponse; + }); + PackageRepositoriesService.getPackageRepositoryDetail = jest.fn().mockImplementationOnce(() => { + return { detail: packageRepositoryDetail } as GetPackageRepositoryDetailResponse; }); - AppRepository.delete = jest.fn(); - AppRepository.get = jest.fn().mockImplementationOnce(() => { - return appRepo; + PackageRepositoriesService.updatePackageRepository = jest.fn().mockImplementationOnce(() => { + return { + packageRepoRef: packageRepoRef, + } as UpdatePackageRepositoryResponse; }); - AppRepository.update = jest.fn(); - AppRepository.create = jest.fn().mockImplementationOnce(() => { - return { appRepository: { metadata: { name: "repo-abc" } } }; + PackageRepositoriesService.addPackageRepository = jest.fn().mockImplementationOnce(() => { + return { + packageRepoRef: packageRepoRef, + } as AddPackageRepositoryResponse; }); }); @@ -64,10 +111,8 @@ interface ITestCase { payload?: any; } -const repo = { metadata: { name: "my-repo" } } as IAppRepository; - -const repoData = { - plugin: { name: "my.plugin", version: "0.0.1" } as Plugin, +const pkgRepoFormData = { + plugin: plugin, authHeader: "", authMethod: PackageRepositoryAuth_PackageRepositoryAuthType.PACKAGE_REPOSITORY_AUTH_TYPE_UNSPECIFIED, @@ -112,11 +157,26 @@ const repoData = { const actionTestCases: ITestCase[] = [ { name: "addRepo", action: repoActions.addRepo }, - { name: "addedRepo", action: repoActions.addedRepo, args: repo, payload: repo }, - { name: "requestRepos", action: repoActions.requestRepos }, - { name: "receiveRepos", action: repoActions.receiveRepos, args: [[repo]], payload: [repo] }, - { name: "requestRepo", action: repoActions.requestRepo }, - { name: "receiveRepo", action: repoActions.receiveRepo, args: repo, payload: repo }, + { + name: "addedRepo", + action: repoActions.addedRepo, + args: packageRepositoryDetail, + payload: packageRepositoryDetail, + }, + { name: "requestRepoSummaries", action: repoActions.requestRepoSummaries }, + { + name: "receiveRepoSummaries", + action: repoActions.receiveRepoSummaries, + args: [[packageRepositorySummary]], + payload: [packageRepositorySummary], + }, + { name: "requestRepoDetail", action: repoActions.requestRepoDetail }, + { + name: "receiveRepoDetail", + action: repoActions.receiveRepoDetail, + args: [packageRepositoryDetail], + payload: packageRepositoryDetail, + }, { name: "redirect", action: repoActions.redirect, args: "/foo", payload: "/foo" }, { name: "redirected", action: repoActions.redirected }, { @@ -144,9 +204,9 @@ actionTestCases.forEach(tc => { // Async action creators describe("deleteRepo", () => { - context("dispatches requestRepos and receivedRepos after deletion if no error", () => { + context("dispatches requestRepoSummaries and receivedRepos after deletion if no error", () => { const currentNamespace = "current-namespace"; - it("dispatches requestRepos with current namespace", async () => { + it("dispatches requestRepoSummaries with current namespace", async () => { const storeWithFlag: any = mockStore({ clusters: { currentCluster: "defaultCluster", @@ -157,13 +217,19 @@ describe("deleteRepo", () => { }, }, }); - await storeWithFlag.dispatch(repoActions.deleteRepo("foo", "my-namespace")); + await storeWithFlag.dispatch( + repoActions.deleteRepo({ + context: { cluster: "default", namespace: "my-namespace" }, + identifier: "foo", + plugin: plugin, + } as PackageRepositoryReference), + ); expect(storeWithFlag.getActions()).toEqual([]); }); }); it("dispatches errorRepos if error deleting", async () => { - AppRepository.delete = jest.fn().mockImplementationOnce(() => { + PackageRepositoriesService.deletePackageRepository = jest.fn().mockImplementationOnce(() => { throw new Error("Boom!"); }); @@ -174,37 +240,45 @@ describe("deleteRepo", () => { }, ]; - await store.dispatch(repoActions.deleteRepo("foo", "my-namespace")); + await store.dispatch( + repoActions.deleteRepo({ + context: { cluster: "default", namespace: "my-namespace" }, + identifier: "foo", + plugin: plugin, + } as PackageRepositoryReference), + ); expect(store.getActions()).toEqual(expectedActions); }); }); -describe("fetchRepos", () => { +describe("fetchRepoSummaries", () => { const namespace = "default"; - it("dispatches requestRepos and receivedRepos if no error", async () => { + it("dispatches requestRepoSummaries and receivedRepos if no error", async () => { const expectedActions = [ { - type: getType(repoActions.requestRepos), + type: getType(repoActions.requestRepoSummaries), payload: namespace, }, { - type: getType(repoActions.receiveRepos), - payload: { foo: "bar" }, + type: getType(repoActions.receiveRepoSummaries), + payload: [packageRepositorySummary], }, ]; - await store.dispatch(repoActions.fetchRepos(namespace)); + await store.dispatch(repoActions.fetchRepoSummaries(namespace)); expect(store.getActions()).toEqual(expectedActions); }); - it("dispatches requestRepos and errorRepos if error fetching", async () => { - AppRepository.list = jest.fn().mockImplementationOnce(() => { - throw new Error("Boom!"); - }); + it("dispatches requestRepoSummaries and errorRepos if error fetching", async () => { + PackageRepositoriesService.getPackageRepositorySummaries = jest + .fn() + .mockImplementationOnce(() => { + throw new Error("Boom!"); + }); const expectedActions = [ { - type: getType(repoActions.requestRepos), + type: getType(repoActions.requestRepoSummaries), payload: namespace, }, { @@ -213,148 +287,175 @@ describe("fetchRepos", () => { }, ]; - await store.dispatch(repoActions.fetchRepos(namespace)); + await store.dispatch(repoActions.fetchRepoSummaries(namespace)); expect(store.getActions()).toEqual(expectedActions); }); it("fetches additional repos from the global namespace and joins them", async () => { - AppRepository.list = jest + PackageRepositoriesService.getPackageRepositorySummaries = jest .fn() .mockImplementationOnce(() => { - return { items: [{ name: "repo1", metadata: { uid: "123" } }] }; + return { + packageRepositorySummaries: [{ name: "repo1", packageRepoRef: { identifier: "repo1" } }], + } as GetPackageRepositorySummariesResponse; }) .mockImplementationOnce(() => { - return { items: [{ name: "repo2", metadata: { uid: "321" } }] }; + return { + packageRepositorySummaries: [{ name: "repo2", packageRepoRef: { identifier: "repo2" } }], + } as GetPackageRepositorySummariesResponse; }); const expectedActions = [ { - type: getType(repoActions.requestRepos), + type: getType(repoActions.requestRepoSummaries), payload: namespace, }, { - type: getType(repoActions.requestRepos), + type: getType(repoActions.requestRepoSummaries), payload: globalReposNamespace, }, { - type: getType(repoActions.receiveRepos), + type: getType(repoActions.receiveRepoSummaries), payload: [ - { name: "repo1", metadata: { uid: "123" } }, - { name: "repo2", metadata: { uid: "321" } }, - ], + { name: "repo1", packageRepoRef: { identifier: "repo1" } }, + { name: "repo2", packageRepoRef: { identifier: "repo2" } }, + ] as PackageRepositorySummary[], }, ]; - - await store.dispatch(repoActions.fetchRepos(namespace, true)); + await store.dispatch(repoActions.fetchRepoSummaries(namespace, true)); expect(store.getActions()).toEqual(expectedActions); }); it("fetches duplicated repos from several namespaces and joins them", async () => { - AppRepository.list = jest + PackageRepositoriesService.getPackageRepositorySummaries = jest .fn() .mockImplementationOnce(() => { - return { items: [{ name: "repo1", metadata: { uid: "123" } }] }; + return { + packageRepositorySummaries: [{ name: "repo1", packageRepoRef: { identifier: "repo1" } }], + } as GetPackageRepositorySummariesResponse; }) .mockImplementationOnce(() => { return { - items: [ - { name: "repo2", metadata: { uid: "321" } }, - { name: "repo3", metadata: { uid: "321" } }, + packageRepositorySummaries: [ + { name: "repo1", packageRepoRef: { identifier: "repo1" } }, + { name: "repo2", packageRepoRef: { identifier: "repo2" } }, ], - }; + } as GetPackageRepositorySummariesResponse; }); const expectedActions = [ { - type: getType(repoActions.requestRepos), + type: getType(repoActions.requestRepoSummaries), payload: namespace, }, { - type: getType(repoActions.requestRepos), + type: getType(repoActions.requestRepoSummaries), payload: globalReposNamespace, }, { - type: getType(repoActions.receiveRepos), + type: getType(repoActions.receiveRepoSummaries), payload: [ - { name: "repo1", metadata: { uid: "123" } }, - { name: "repo2", metadata: { uid: "321" } }, - ], + { name: "repo1", packageRepoRef: { identifier: "repo1" } }, + { name: "repo2", packageRepoRef: { identifier: "repo2" } }, + ] as PackageRepositorySummary[], }, ]; - await store.dispatch(repoActions.fetchRepos(namespace, true)); + await store.dispatch(repoActions.fetchRepoSummaries(namespace, true)); expect(store.getActions()).toEqual(expectedActions); }); it("fetches repos only if the namespace is the one used for global repos", async () => { - AppRepository.list = jest + PackageRepositoriesService.getPackageRepositorySummaries = jest .fn() .mockImplementationOnce(() => { - return { items: [{ name: "repo1", metadata: { uid: "123" } }] }; + return { + packageRepositorySummaries: [{ name: "repo1" }], + } as GetPackageRepositorySummariesResponse; }) .mockImplementationOnce(() => { return { - items: [ - { name: "repo1", metadata: { uid: "321" } }, - { name: "repo2", metadata: { uid: "123" } }, - ], - }; + packageRepositorySummaries: [{ name: "repo1" }, { name: "repo2" }], + } as GetPackageRepositorySummariesResponse; }); const expectedActions = [ { - type: getType(repoActions.requestRepos), + type: getType(repoActions.requestRepoSummaries), payload: globalReposNamespace, }, { - type: getType(repoActions.receiveRepos), - payload: [{ name: "repo1", metadata: { uid: "123" } }], + type: getType(repoActions.receiveRepoSummaries), + payload: [{ name: "repo1" }], }, ]; - await store.dispatch(repoActions.fetchRepos(globalReposNamespace, true)); + await store.dispatch(repoActions.fetchRepoSummaries(globalReposNamespace, true)); expect(store.getActions()).toEqual(expectedActions); }); }); describe("installRepo", () => { - const installRepoCMD = repoActions.installRepo("my-namespace", repoData); + const installRepoCMD = repoActions.installRepo("my-namespace", pkgRepoFormData); context("when authHeader provided", () => { const installRepoCMDAuth = repoActions.installRepo("my-namespace", { - ...repoData, + ...pkgRepoFormData, authHeader: "Bearer: abc", }); - it("calls AppRepository create including a auth struct (authHeader)", async () => { + it("calls PackageRepositoriesService create including a auth struct (authHeader)", async () => { await store.dispatch(installRepoCMDAuth); - expect(AppRepository.create).toHaveBeenCalledWith("default", "my-namespace", { - ...repoData, - authHeader: "Bearer: abc", - }); + expect(PackageRepositoriesService.addPackageRepository).toHaveBeenCalledWith( + "default", + "my-namespace", + { + ...pkgRepoFormData, + authHeader: "Bearer: abc", + }, + true, + ); }); - it("calls AppRepository create including ociRepositories", async () => { + it("calls PackageRepositoriesService create including ociRepositories", async () => { await store.dispatch( repoActions.installRepo("my-namespace", { - ...repoData, + ...pkgRepoFormData, type: RepositoryStorageTypes.PACKAGE_REPOSITORY_STORAGE_OCI, - customDetails: { ...repoData.customDetails, ociRepositories: ["apache", "jenkins"] }, + customDetails: { + ...pkgRepoFormData.customDetails, + ociRepositories: ["apache", "jenkins"], + }, }), ); - expect(AppRepository.create).toHaveBeenCalledWith("default", "my-namespace", { - ...repoData, - type: RepositoryStorageTypes.PACKAGE_REPOSITORY_STORAGE_OCI, - customDetails: { ...repoData.customDetails, ociRepositories: ["apache", "jenkins"] }, - }); + expect(PackageRepositoriesService.addPackageRepository).toHaveBeenCalledWith( + "default", + "my-namespace", + { + ...pkgRepoFormData, + type: RepositoryStorageTypes.PACKAGE_REPOSITORY_STORAGE_OCI, + customDetails: { + ...pkgRepoFormData.customDetails, + ociRepositories: ["apache", "jenkins"], + }, + }, + true, + ); }); - it("calls AppRepository create skipping TLS verification", async () => { - await store.dispatch(repoActions.installRepo("my-namespace", { ...repoData, skipTLS: true })); - expect(AppRepository.create).toHaveBeenCalledWith("default", "my-namespace", { - ...repoData, - skipTLS: true, - }); + it("calls PackageRepositoriesService create skipping TLS verification", async () => { + await store.dispatch( + repoActions.installRepo("my-namespace", { ...pkgRepoFormData, skipTLS: true }), + ); + expect(PackageRepositoriesService.addPackageRepository).toHaveBeenCalledWith( + "default", + "my-namespace", + { + ...pkgRepoFormData, + skipTLS: true, + }, + true, + ); }); it("returns true", async () => { @@ -365,16 +466,21 @@ describe("installRepo", () => { context("when a customCA is provided", () => { const installRepoCMDAuth = repoActions.installRepo("my-namespace", { - ...repoData, + ...pkgRepoFormData, customCA: "This is a cert!", }); - it("calls AppRepository create including a auth struct (custom CA)", async () => { + it("calls PackageRepositoriesService create including a auth struct (custom CA)", async () => { await store.dispatch(installRepoCMDAuth); - expect(AppRepository.create).toHaveBeenCalledWith("default", "my-namespace", { - ...repoData, - customCA: "This is a cert!", - }); + expect(PackageRepositoriesService.addPackageRepository).toHaveBeenCalledWith( + "default", + "my-namespace", + { + ...pkgRepoFormData, + customCA: "This is a cert!", + }, + true, + ); }); it("returns true (installRepoCMDAuth)", async () => { @@ -384,9 +490,14 @@ describe("installRepo", () => { }); context("when authHeader and customCA are empty", () => { - it("calls AppRepository create without a auth struct", async () => { + it("calls PackageRepositoriesService create without a auth struct", async () => { await store.dispatch(installRepoCMD); - expect(AppRepository.create).toHaveBeenCalledWith("default", "my-namespace", repoData); + expect(PackageRepositoriesService.addPackageRepository).toHaveBeenCalledWith( + "default", + "my-namespace", + pkgRepoFormData, + true, + ); }); it("returns true (installRepoCMD)", async () => { @@ -396,7 +507,7 @@ describe("installRepo", () => { }); it("dispatches addRepo and errorRepos if error fetching", async () => { - AppRepository.create = jest.fn().mockImplementationOnce(() => { + PackageRepositoriesService.addPackageRepository = jest.fn().mockImplementationOnce(() => { throw new Error("Boom!"); }); @@ -415,7 +526,7 @@ describe("installRepo", () => { }); it("returns false if error fetching", async () => { - AppRepository.create = jest.fn().mockImplementationOnce(() => { + PackageRepositoriesService.addPackageRepository = jest.fn().mockImplementationOnce(() => { throw new Error("Boom!"); }); @@ -430,7 +541,7 @@ describe("installRepo", () => { }, { type: getType(repoActions.addedRepo), - payload: { metadata: { name: "repo-abc" } }, + payload: convertPkgRepoDetailToSummary(packageRepositoryDetail), }, ]; @@ -441,94 +552,125 @@ describe("installRepo", () => { it("includes registry secrets if given", async () => { await store.dispatch( repoActions.installRepo("my-namespace", { - ...repoData, - customDetails: { ...repoData.customDetails, dockerRegistrySecrets: ["repo-1"] }, + ...pkgRepoFormData, + customDetails: { ...pkgRepoFormData.customDetails, dockerRegistrySecrets: ["repo-1"] }, }), ); - expect(AppRepository.create).toHaveBeenCalledWith("default", "my-namespace", { - ...repoData, - customDetails: { ...repoData.customDetails, dockerRegistrySecrets: ["repo-1"] }, - }); + expect(PackageRepositoriesService.addPackageRepository).toHaveBeenCalledWith( + "default", + "my-namespace", + { + ...pkgRepoFormData, + customDetails: { ...pkgRepoFormData.customDetails, dockerRegistrySecrets: ["repo-1"] }, + }, + true, + ); }); - it("calls AppRepository create with description", async () => { + it("calls PackageRepositoriesService create with description", async () => { await store.dispatch( repoActions.installRepo("my-namespace", { - ...repoData, + ...pkgRepoFormData, description: "This is a weird description 123!@#$%^&&*()_+-=<>?/.,;:'\"", }), ); - expect(AppRepository.create).toHaveBeenCalledWith("default", "my-namespace", { - ...repoData, - description: "This is a weird description 123!@#$%^&&*()_+-=<>?/.,;:'\"", - }); + expect(PackageRepositoriesService.addPackageRepository).toHaveBeenCalledWith( + "default", + "my-namespace", + { + ...pkgRepoFormData, + description: "This is a weird description 123!@#$%^&&*()_+-=<>?/.,;:'\"", + }, + true, + ); }); }); describe("updateRepo", () => { it("updates a repo with an auth header", async () => { - const r = { - metadata: { name: "repo-abc" }, - spec: { auth: { header: { secretKeyRef: { name: "apprepo-repo-abc" } } } }, - }; - AppRepository.update = jest.fn().mockReturnValue({ - appRepository: r, - }); + const pkgRepoDetail = { + ...packageRepositoryDetail, + auth: { + header: "foo", + }, + } as PackageRepositoryDetail; + + PackageRepositoriesService.updatePackageRepository = jest.fn().mockReturnValue({ + packageRepoRef: pkgRepoDetail.packageRepoRef, + } as UpdatePackageRepositoryResponse); + PackageRepositoriesService.getPackageRepositoryDetail = jest.fn().mockReturnValue({ + detail: pkgRepoDetail, + } as GetPackageRepositoryDetailResponse); const expectedActions = [ { type: getType(repoActions.requestRepoUpdate), }, { type: getType(repoActions.repoUpdated), - payload: r, + payload: convertPkgRepoDetailToSummary(pkgRepoDetail), }, ]; await store.dispatch( repoActions.updateRepo("my-namespace", { - ...repoData, + ...pkgRepoFormData, authHeader: "foo", }), ); expect(store.getActions()).toEqual(expectedActions); - expect(AppRepository.update).toHaveBeenCalledWith("default", "my-namespace", { - ...repoData, - authHeader: "foo", - }); + expect(PackageRepositoriesService.updatePackageRepository).toHaveBeenCalledWith( + "default", + "my-namespace", + { + ...pkgRepoFormData, + authHeader: "foo", + }, + ); }); it("updates a repo with an customCA", async () => { - const r = { - metadata: { name: "repo-abc" }, - spec: { auth: { customCA: { secretKeyRef: { name: "apprepo-repo-abc" } } } }, - }; - AppRepository.update = jest.fn().mockReturnValue({ - appRepository: r, - }); + const pkgRepoDetail = { + ...packageRepositoryDetail, + tlsConfig: { + secretRef: { name: "pkgrepo-repo-abc", key: "data" }, + certAuthority: "", + insecureSkipVerify: false, + }, + } as PackageRepositoryDetail; + PackageRepositoriesService.updatePackageRepository = jest.fn().mockReturnValue({ + packageRepoRef: packageRepositoryDetail.packageRepoRef, + } as UpdatePackageRepositoryResponse); + PackageRepositoriesService.getPackageRepositoryDetail = jest.fn().mockReturnValue({ + detail: pkgRepoDetail, + } as GetPackageRepositoryDetailResponse); const expectedActions = [ { type: getType(repoActions.requestRepoUpdate), }, { type: getType(repoActions.repoUpdated), - payload: r, + payload: convertPkgRepoDetailToSummary(pkgRepoDetail), }, ]; await store.dispatch( - repoActions.updateRepo("my-namespace", { ...repoData, customCA: "This is a cert!" }), + repoActions.updateRepo("my-namespace", { ...pkgRepoFormData, customCA: "This is a cert!" }), ); expect(store.getActions()).toEqual(expectedActions); - expect(AppRepository.update).toHaveBeenCalledWith("default", "my-namespace", { - ...repoData, - customCA: "This is a cert!", - }); + expect(PackageRepositoriesService.updatePackageRepository).toHaveBeenCalledWith( + "default", + "my-namespace", + { + ...pkgRepoFormData, + customCA: "This is a cert!", + }, + ); }); it("returns an error if failed", async () => { - AppRepository.update = jest.fn(() => { + PackageRepositoriesService.updatePackageRepository = jest.fn(() => { throw new Error("boom"); }); const expectedActions = [ @@ -541,39 +683,50 @@ describe("updateRepo", () => { }, ]; - await store.dispatch(repoActions.updateRepo("my-namespace", repoData)); + await store.dispatch(repoActions.updateRepo("my-namespace", pkgRepoFormData)); expect(store.getActions()).toEqual(expectedActions); }); it("updates a repo with ociRepositories", async () => { - AppRepository.update = jest.fn().mockReturnValue({ - appRepository: {}, - }); + PackageRepositoriesService.updatePackageRepository = jest.fn().mockReturnValue({ + packageRepoRef: packageRepositoryDetail.packageRepoRef, + } as UpdatePackageRepositoryResponse); await store.dispatch( repoActions.updateRepo("my-namespace", { - ...repoData, + ...pkgRepoFormData, type: RepositoryStorageTypes.PACKAGE_REPOSITORY_STORAGE_OCI, - customDetails: { ...repoData.customDetails, ociRepositories: ["apache", "jenkins"] }, + customDetails: { ...pkgRepoFormData.customDetails, ociRepositories: ["apache", "jenkins"] }, }), ); - expect(AppRepository.update).toHaveBeenCalledWith("default", "my-namespace", { - ...repoData, - type: RepositoryStorageTypes.PACKAGE_REPOSITORY_STORAGE_OCI, - customDetails: { ...repoData.customDetails, ociRepositories: ["apache", "jenkins"] }, - }); + expect(PackageRepositoriesService.updatePackageRepository).toHaveBeenCalledWith( + "default", + "my-namespace", + { + ...pkgRepoFormData, + type: RepositoryStorageTypes.PACKAGE_REPOSITORY_STORAGE_OCI, + customDetails: { ...pkgRepoFormData.customDetails, ociRepositories: ["apache", "jenkins"] }, + }, + ); }); it("updates a repo with description", async () => { - AppRepository.update = jest.fn().mockReturnValue({ - appRepository: {}, - }); + PackageRepositoriesService.updatePackageRepository = jest.fn().mockReturnValue({ + packageRepoRef: packageRepositoryDetail.packageRepoRef, + } as UpdatePackageRepositoryResponse); await store.dispatch( - repoActions.updateRepo("my-namespace", { ...repoData, description: "updated description" }), + repoActions.updateRepo("my-namespace", { + ...pkgRepoFormData, + description: "updated description", + }), + ); + expect(PackageRepositoriesService.updatePackageRepository).toHaveBeenCalledWith( + "default", + "my-namespace", + { + ...pkgRepoFormData, + description: "updated description", + }, ); - expect(AppRepository.update).toHaveBeenCalledWith("default", "my-namespace", { - ...repoData, - description: "updated description", - }); }); }); @@ -582,18 +735,18 @@ describe("findPackageInRepo", () => { availablePackageRef: { context: { cluster: "default", namespace: "my-ns" }, identifier: "my-repo/my-package", - plugin: { name: "my.plugin", version: "0.0.1" } as Plugin, + plugin: plugin, }, } as InstalledPackageDetail; it("dispatches requestRepo and receivedRepo if no error", async () => { PackagesService.getAvailablePackageVersions = jest.fn(); const expectedActions = [ { - type: getType(repoActions.requestRepo), + type: getType(repoActions.requestRepoDetail), }, { - type: getType(repoActions.receiveRepo), - payload: appRepo, + type: getType(repoActions.receiveRepoDetail), + payload: packageRepositoryDetail, }, ]; await store.dispatch( @@ -608,7 +761,7 @@ describe("findPackageInRepo", () => { expect(PackagesService.getAvailablePackageVersions).toBeCalledWith({ context: { cluster: "default", namespace: "other-namespace" }, identifier: "my-repo/my-package", - plugin: { name: "my.plugin", version: "0.0.1" } as Plugin, + plugin: plugin, } as AvailablePackageReference); }); @@ -619,7 +772,7 @@ describe("findPackageInRepo", () => { const expectedActions = [ { - type: getType(repoActions.requestRepo), + type: getType(repoActions.requestRepoDetail), }, { type: getType(actions.availablepackages.createErrorPackage), @@ -641,7 +794,7 @@ describe("findPackageInRepo", () => { expect(PackagesService.getAvailablePackageVersions).toBeCalledWith({ context: { cluster: "default", namespace: "other-namespace" }, identifier: "my-repo/my-package", - plugin: { name: "my.plugin", version: "0.0.1" } as Plugin, + plugin: plugin, } as AvailablePackageReference); }); }); diff --git a/dashboard/src/actions/repos.ts b/dashboard/src/actions/repos.ts index b368cf98a73..43217d6a5ea 100644 --- a/dashboard/src/actions/repos.ts +++ b/dashboard/src/actions/repos.ts @@ -5,43 +5,44 @@ import { AvailablePackageReference, InstalledPackageDetail, } from "gen/kubeappsapis/core/packages/v1alpha1/packages"; +import { + PackageRepositoryDetail, + PackageRepositoryReference, + PackageRepositorySummary, +} from "gen/kubeappsapis/core/packages/v1alpha1/repositories"; import { uniqBy } from "lodash"; import { ThunkAction } from "redux-thunk"; -import { AppRepository } from "shared/AppRepository"; +import { PackageRepositoriesService } from "shared/PackageRepositoriesService"; import PackagesService from "shared/PackagesService"; -import { IAppRepository, IPkgRepoFormData, IStoreState, NotFoundError } from "shared/types"; +import { IPkgRepoFormData, IStoreState, NotFoundError } from "shared/types"; +import { PluginNames } from "shared/utils"; import { ActionType, deprecated } from "typesafe-actions"; import { createErrorPackage } from "./availablepackages"; const { createAction } = deprecated; export const addRepo = createAction("ADD_REPO"); export const addedRepo = createAction("ADDED_REPO", resolve => { - return (added: IAppRepository) => resolve(added); + return (added: PackageRepositorySummary) => resolve(added); }); export const requestRepoUpdate = createAction("REQUEST_REPO_UPDATE"); export const repoUpdated = createAction("REPO_UPDATED", resolve => { - return (updated: IAppRepository) => resolve(updated); + return (updated: PackageRepositorySummary) => resolve(updated); }); -export const requestRepos = createAction("REQUEST_REPOS", resolve => { +export const requestRepoSummaries = createAction("REQUEST_REPOS", resolve => { return (namespace: string) => resolve(namespace); }); -export const receiveRepos = createAction("RECEIVE_REPOS", resolve => { - return (repos: IAppRepository[]) => resolve(repos); +export const receiveRepoSummaries = createAction("RECEIVE_REPOS", resolve => { + return (repos: PackageRepositorySummary[]) => resolve(repos); }); export const concatRepos = createAction("RECEIVE_REPOS", resolve => { - return (repos: IAppRepository[]) => resolve(repos); -}); - -export const requestRepo = createAction("REQUEST_REPO"); -export const receiveRepo = createAction("RECEIVE_REPO", resolve => { - return (repo: IAppRepository) => resolve(repo); + return (repos: PackageRepositorySummary[]) => resolve(repos); }); -export const repoValidating = createAction("REPO_VALIDATING"); -export const repoValidated = createAction("REPO_VALIDATED", resolve => { - return (data: any) => resolve(data); +export const requestRepoDetail = createAction("REQUEST_REPO"); +export const receiveRepoDetail = createAction("RECEIVE_REPO", resolve => { + return (repo: PackageRepositoryDetail) => resolve(repo); }); export const redirect = createAction("REDIRECT", resolve => { @@ -59,42 +60,49 @@ const allActions = [ addedRepo, requestRepoUpdate, repoUpdated, - repoValidating, - repoValidated, errorRepos, - requestRepos, - receiveRepo, - receiveRepos, + requestRepoSummaries, + receiveRepoDetail, + receiveRepoSummaries, createErrorPackage, - requestRepo, + requestRepoDetail, redirect, redirected, ]; -export type AppReposAction = ActionType; +export type PkgReposAction = ActionType; -// fetchRepos fetches the AppRepositories in a specified namespace. -export const fetchRepos = ( +// fetchRepos fetches the PackageRepositories in a specified namespace. +export const fetchRepoSummaries = ( namespace: string, listGlobal?: boolean, -): ThunkAction, IStoreState, null, AppReposAction> => { +): ThunkAction, IStoreState, null, PkgReposAction> => { return async (dispatch, getState) => { const { clusters: { currentCluster }, config: { globalReposNamespace }, } = getState(); try { - dispatch(requestRepos(namespace)); - const repos = await AppRepository.list(currentCluster, namespace); + dispatch(requestRepoSummaries(namespace)); + const repos = await PackageRepositoriesService.getPackageRepositorySummaries({ + cluster: currentCluster, + namespace: namespace, + }); if (!listGlobal || namespace === globalReposNamespace) { - dispatch(receiveRepos(repos.items)); + dispatch(receiveRepoSummaries(repos.packageRepositorySummaries)); } else { // Global repos need to be added - let totalRepos = repos.items; - dispatch(requestRepos(globalReposNamespace)); - const globalRepos = await AppRepository.list(currentCluster, globalReposNamespace); + let totalRepos = repos.packageRepositorySummaries; + dispatch(requestRepoSummaries(globalReposNamespace)); + const globalRepos = await PackageRepositoriesService.getPackageRepositorySummaries({ + cluster: currentCluster, + namespace: "", + }); // Avoid adding duplicated repos: if two repos have the same uid, filter out - totalRepos = uniqBy(totalRepos.concat(globalRepos.items), "metadata.uid"); - dispatch(receiveRepos(totalRepos)); + totalRepos = uniqBy( + totalRepos.concat(globalRepos.packageRepositorySummaries), + "packageRepoRef.identifier", + ); + dispatch(receiveRepoSummaries(totalRepos)); } } catch (e: any) { dispatch(errorRepos(e, "fetch")); @@ -103,24 +111,26 @@ export const fetchRepos = ( }; export const fetchRepo = ( - cluster: string, - repoNamespace: string, - repoName: string, -): ThunkAction, IStoreState, null, AppReposAction> => { + packageRepoRef: PackageRepositoryReference, +): ThunkAction, IStoreState, null, PkgReposAction> => { return async dispatch => { try { - dispatch(requestRepo()); - const appRepository = await AppRepository.get(cluster, repoNamespace, repoName); - if (!appRepository) { + dispatch(requestRepoDetail()); + // Check if we have enough data to retrieve the package manually (instead of using its own availablePackageRef) + const getPackageRepositoryDetailResponse = + await PackageRepositoriesService.getPackageRepositoryDetail(packageRepoRef); + if (!getPackageRepositoryDetailResponse?.detail) { dispatch( errorRepos( - new Error(`Can't get the repository: ${JSON.stringify(appRepository)}`), + new Error( + `Can't get the repository: ${JSON.stringify(getPackageRepositoryDetailResponse)}`, + ), "fetch", ), ); return false; } - dispatch(receiveRepo(appRepository.data)); + dispatch(receiveRepoDetail(getPackageRepositoryDetailResponse.detail)); return true; } catch (e: any) { dispatch(errorRepos(e, "fetch")); @@ -132,16 +142,56 @@ export const fetchRepo = ( export const installRepo = ( namespace: string, request: IPkgRepoFormData, -): ThunkAction, IStoreState, null, AppReposAction> => { +): ThunkAction, IStoreState, null, PkgReposAction> => { return async (dispatch, getState) => { const { clusters: { currentCluster }, + config: { globalReposNamespace }, } = getState(); try { dispatch(addRepo()); - const data = await AppRepository.create(currentCluster, namespace, request); - dispatch(addedRepo(data.appRepository)); + let namespaceScoped = namespace !== globalReposNamespace; + // TODO(agamez): currently, flux doesn't support this value to be true + if (request.plugin?.name === PluginNames.PACKAGES_FLUX) { + namespaceScoped = false; + } + + const addPackageRepositoryResponse = await PackageRepositoriesService.addPackageRepository( + currentCluster, + namespace, + request, + namespaceScoped, + ); + // Ensure the repo have been created + if (!addPackageRepositoryResponse?.packageRepoRef) { + dispatch( + errorRepos( + new Error( + `Can't create the repository: ${JSON.stringify(addPackageRepositoryResponse)}`, + ), + "create", + ), + ); + return false; + } + const getPackageRepositoryDetailResponse = + await PackageRepositoriesService.getPackageRepositoryDetail( + addPackageRepositoryResponse.packageRepoRef, + ); + if (!getPackageRepositoryDetailResponse?.detail) { + dispatch( + errorRepos( + new Error( + `The repo wasn't created: ${JSON.stringify(getPackageRepositoryDetailResponse)}`, + ), + "create", + ), + ); + return false; + } + const repoSummary = convertPkgRepoDetailToSummary(getPackageRepositoryDetailResponse.detail); + dispatch(addedRepo(repoSummary)); return true; } catch (e: any) { dispatch(errorRepos(e, "create")); @@ -153,15 +203,49 @@ export const installRepo = ( export const updateRepo = ( namespace: string, request: IPkgRepoFormData, -): ThunkAction, IStoreState, null, AppReposAction> => { +): ThunkAction, IStoreState, null, PkgReposAction> => { return async (dispatch, getState) => { const { clusters: { currentCluster }, } = getState(); try { dispatch(requestRepoUpdate()); - const data = await AppRepository.update(currentCluster, namespace, request); - dispatch(repoUpdated(data.appRepository)); + const updatePackageRepositoryResponse = + await PackageRepositoriesService.updatePackageRepository( + currentCluster, + namespace, + request, + ); + + // Ensure the repo have been updated + if (!updatePackageRepositoryResponse?.packageRepoRef) { + dispatch( + errorRepos( + new Error( + `Can't update the repository: ${JSON.stringify(updatePackageRepositoryResponse)}`, + ), + "update", + ), + ); + return false; + } + const getPackageRepositoryDetailResponse = + await PackageRepositoriesService.getPackageRepositoryDetail( + updatePackageRepositoryResponse.packageRepoRef, + ); + if (!getPackageRepositoryDetailResponse?.detail) { + dispatch( + errorRepos( + new Error( + `The repo wasn't updated: ${JSON.stringify(getPackageRepositoryDetailResponse)}`, + ), + "update", + ), + ); + return false; + } + const repoSummary = convertPkgRepoDetailToSummary(getPackageRepositoryDetailResponse.detail); + dispatch(repoUpdated(repoSummary)); return true; } catch (e: any) { dispatch(errorRepos(e, "update")); @@ -171,15 +255,11 @@ export const updateRepo = ( }; export const deleteRepo = ( - name: string, - namespace: string, -): ThunkAction, IStoreState, null, AppReposAction> => { - return async (dispatch, getState) => { - const { - clusters: { currentCluster }, - } = getState(); + packageRepoRef: PackageRepositoryReference, +): ThunkAction, IStoreState, null, PkgReposAction> => { + return async dispatch => { try { - await AppRepository.delete(currentCluster, namespace, name); + await PackageRepositoriesService.deletePackageRepository(packageRepoRef); return true; } catch (e: any) { dispatch(errorRepos(e, "delete")); @@ -188,24 +268,39 @@ export const deleteRepo = ( }; }; -export function findPackageInRepo( +export const findPackageInRepo = ( cluster: string, repoNamespace: string, repoName: string, app?: InstalledPackageDetail, -): ThunkAction, IStoreState, null, AppReposAction> { +): ThunkAction, IStoreState, null, PkgReposAction> => { return async dispatch => { - dispatch(requestRepo()); + dispatch(requestRepoDetail()); // Check if we have enough data to retrieve the package manually (instead of using its own availablePackageRef) if (app?.availablePackageRef?.identifier && app?.availablePackageRef?.plugin) { - const appRepository = await AppRepository.get(cluster, repoNamespace, repoName); + const getPackageRepositoryDetailResponse = + await PackageRepositoriesService.getPackageRepositoryDetail({ + identifier: repoName, + context: { cluster, namespace: repoNamespace }, + plugin: app.availablePackageRef.plugin, + }); try { await PackagesService.getAvailablePackageVersions({ context: { cluster: cluster, namespace: repoNamespace }, plugin: app.availablePackageRef.plugin, identifier: app.availablePackageRef.identifier, } as AvailablePackageReference); - dispatch(receiveRepo(appRepository)); + if (!getPackageRepositoryDetailResponse?.detail) { + dispatch( + createErrorPackage( + new NotFoundError( + `Package ${app.availablePackageRef.identifier} not found in the repository ${repoNamespace}.`, + ), + ), + ); + return false; + } + dispatch(receiveRepoDetail(getPackageRepositoryDetailResponse.detail)); return true; } catch (e: any) { dispatch( @@ -228,48 +323,20 @@ export function findPackageInRepo( return false; } }; -} +}; -// to be deprecated -export const validateRepo = ( - repoURL: string, - type: string, - authHeader: string, - authRegCreds: string, - customCA: string, - ociRepositories: string[], - skipTLS: boolean, - passCredentials: boolean, -): ThunkAction, IStoreState, null, AppReposAction> => { - return async (dispatch, getState) => { - const { - clusters: { currentCluster, clusters }, - } = getState(); - const namespace = clusters[currentCluster].currentNamespace; - try { - dispatch(repoValidating()); - const data = await AppRepository.validate( - currentCluster, - namespace, - repoURL, - type, - authHeader, - authRegCreds, - customCA, - ociRepositories, - skipTLS, - passCredentials, - ); - if (data.code === 200) { - dispatch(repoValidated(data)); - return true; - } else { - dispatch(errorRepos(new Error(JSON.stringify(data)), "validate")); - return false; - } - } catch (e: any) { - dispatch(errorRepos(e, "validate")); - return false; - } +export const convertPkgRepoDetailToSummary = ( + repoDetail: PackageRepositoryDetail, +): PackageRepositorySummary => { + const repoSummary: PackageRepositorySummary = { + description: repoDetail.description, + name: repoDetail.name, + type: repoDetail.type, + url: repoDetail.url, + namespaceScoped: repoDetail.namespaceScoped, + requiresAuth: !!repoDetail.auth, + packageRepoRef: repoDetail.packageRepoRef, + status: repoDetail.status, }; + return repoSummary; }; diff --git a/dashboard/src/components/AppUpgrade/AppUpgrade.test.tsx b/dashboard/src/components/AppUpgrade/AppUpgrade.test.tsx index 664efa0a5ef..4093d975075 100644 --- a/dashboard/src/components/AppUpgrade/AppUpgrade.test.tsx +++ b/dashboard/src/components/AppUpgrade/AppUpgrade.test.tsx @@ -14,16 +14,19 @@ import { PackageAppVersion, VersionReference, } from "gen/kubeappsapis/core/packages/v1alpha1/packages"; +import { + PackageRepositoryDetail, + PackageRepositorySummary, +} from "gen/kubeappsapis/core/packages/v1alpha1/repositories"; import { Plugin } from "gen/kubeappsapis/core/plugins/v1alpha1/plugins"; import * as ReactRedux from "react-redux"; import * as ReactRouter from "react-router"; import { MemoryRouter, Route } from "react-router-dom"; -import { IAppRepositoryState } from "reducers/repos"; +import { IPackageRepositoryState } from "reducers/repos"; import { defaultStore, getStore, mountWrapper } from "shared/specs/mountWrapper"; import { CustomInstalledPackageDetail, FetchError, - IAppRepository, IInstalledPackageState, IPackageState, UpgradeError, @@ -84,12 +87,23 @@ const selectedPackage = { availablePackageDetail: { name: "test" } as AvailablePackageDetail, } as IPackageState["selected"]; -const repo1 = { - metadata: { - name: defaultProps.repo, - namespace: defaultProps.repoNamespace, +const repo1Summary = { + name: defaultProps.repo, + packageRepoRef: { + context: { namespace: defaultProps.repoNamespace, cluster: defaultProps.cluster }, + identifier: defaultProps.repo, + plugin: defaultProps.plugin, + }, +} as PackageRepositorySummary; + +const repo1Detail = { + name: defaultProps.repo, + packageRepoRef: { + context: { namespace: defaultProps.repoNamespace, cluster: defaultProps.cluster }, + identifier: defaultProps.repo, + plugin: defaultProps.plugin, }, -} as IAppRepository; +} as PackageRepositoryDetail; let spyOnUseDispatch: jest.SpyInstance; let spyOnUseHistory: jest.SpyInstance; @@ -131,8 +145,8 @@ it("renders the repo selection form if not introduced", () => { it("renders the repo selection form if not introduced when the app is loaded", () => { const state = { repos: { - repos: [repo1], - } as IAppRepositoryState, + repos: [repo1Summary], + } as IPackageRepositoryState, apps: { selected: { name: "foo" }, isFetching: false, @@ -184,8 +198,8 @@ describe("when an error exists", () => { it("renders a warning message if there are no repositories", () => { const state = { repos: { - repos: [] as IAppRepository[], - } as IAppRepositoryState, + repos: [] as PackageRepositorySummary[], + } as IPackageRepositoryState, apps: { selected: { name: "foo" }, isFetching: false, @@ -249,10 +263,10 @@ it("renders the upgrade form when the repo is available, clears state and fetche selectedDetails: availablePackageDetail, } as IInstalledPackageState, repos: { - repo: repo1, - repos: [repo1], + repo: repo1Detail, + repos: [repo1Summary], isFetching: false, - } as IAppRepositoryState, + } as IPackageRepositoryState, packages: { selected: selectedPackage } as IPackageState, }; const wrapper = mountWrapper( @@ -285,10 +299,10 @@ it("renders the upgrade form with the version property", () => { selectedDetails: availablePackageDetail, } as IInstalledPackageState, repos: { - repo: repo1, - repos: [repo1], + repo: repo1Detail, + repos: [repo1Summary], isFetching: false, - } as IAppRepositoryState, + } as Partial, packages: { selected: selectedPackage } as IPackageState, }; const wrapper = mountWrapper( @@ -313,10 +327,10 @@ it("skips the repo selection form if the app contains upgrade info", () => { selectedDetails: availablePackageDetail, } as IInstalledPackageState, repos: { - repo: repo1, - repos: [repo1], + repo: repo1Detail, + repos: [repo1Summary], isFetching: false, - } as IAppRepositoryState, + } as IPackageRepositoryState, packages: { selected: selectedPackage } as IPackageState, }; const wrapper = mountWrapper( diff --git a/dashboard/src/components/AppView/AppView.tsx b/dashboard/src/components/AppView/AppView.tsx index ef51b5eae6a..8a323762949 100644 --- a/dashboard/src/components/AppView/AppView.tsx +++ b/dashboard/src/components/AppView/AppView.tsx @@ -92,8 +92,12 @@ function parseResources(resourceRefs: Array) { return result; } -function getButtons(app: CustomInstalledPackageDetail, error: any, revision: number) { - if (!app || !app?.installedPackageRef || !app.installedPackageRef.plugin) { +function getButtons(installedPkg: CustomInstalledPackageDetail, error: any, revision: number) { + if ( + !installedPkg || + !installedPkg?.installedPackageRef || + !installedPkg.installedPackageRef.plugin + ) { return []; } @@ -103,20 +107,20 @@ function getButtons(app: CustomInstalledPackageDetail, error: any, revision: num buttons.push( , ); // Rollback is a helm-only operation, it will only be available for helm-plugin packages - if (getPluginsSupportingRollback().includes(app.installedPackageRef.plugin.name)) { + if (getPluginsSupportingRollback().includes(installedPkg.installedPackageRef.plugin.name)) { buttons.push( , ); @@ -126,8 +130,8 @@ function getButtons(app: CustomInstalledPackageDetail, error: any, revision: num buttons.push( , ); @@ -156,7 +160,7 @@ export default function AppView() { secrets: [], } as IAppViewResourceRefs); const { - apps: { error, selected: app, selectedDetails: appDetails }, + apps: { error, selected: selectedInstalledPkg, selectedDetails: selectedAvailablePkg }, config: { customAppViews }, } = useSelector((state: IStoreState) => state); @@ -226,10 +230,10 @@ export default function AppView() { }, [resourceRefs]); useEffect(() => { - if (!app?.installedPackageRef) { + if (!selectedInstalledPkg?.installedPackageRef) { return () => {}; } - const installedPackageRef = app.installedPackageRef; + const installedPackageRef = selectedInstalledPkg.installedPackageRef; // Watch Deployments, StatefulSets, DaemonSets, Ingresses and Services. const refsToWatch = appViewResourceRefs.deployments.concat( appViewResourceRefs.statefulsets, @@ -249,7 +253,7 @@ export default function AppView() { }; } return () => {}; - }, [dispatch, app?.installedPackageRef, appViewResourceRefs]); + }, [dispatch, selectedInstalledPkg?.installedPackageRef, appViewResourceRefs]); const forceRetry = () => { dispatch(actions.installedpackages.clearErrorInstalledPackage()); @@ -270,24 +274,34 @@ export default function AppView() { } const { services, ingresses, deployments, statefulsets, daemonsets, secrets, otherResources } = appViewResourceRefs; - const revision = app?.revision ?? 0; - const icon = appDetails?.iconUrl ?? placeholder; + const revision = selectedInstalledPkg?.revision ?? 0; + const icon = selectedAvailablePkg?.iconUrl ?? placeholder; // If the package identifier matches the current list of loaded customAppViews, // then load the custom view from external bundle instead of the default one. - const appRepo = app?.availablePackageRef?.identifier.split("/")[0]; - const appName = app?.availablePackageRef?.identifier.split("/")[1]; - const appPlugin = app?.availablePackageRef?.plugin?.name; + const pkgRepo = selectedInstalledPkg?.availablePackageRef?.identifier.split("/")[0]; + const pkgName = selectedInstalledPkg?.availablePackageRef?.identifier.split("/")[1]; + const pkgPlugin = selectedInstalledPkg?.availablePackageRef?.plugin?.name; if ( customAppViews.some( - entry => entry.name === appName && entry.plugin === appPlugin && entry.repository === appRepo, + entry => entry.name === pkgName && entry.plugin === pkgPlugin && entry.repository === pkgRepo, ) ) { - return ; + return ( + + ); } return ( - - {!app || !app?.installedPackageRef ? ( + + {!selectedInstalledPkg || !selectedInstalledPkg?.installedPackageRef ? ( There is a problem with this package ) : (
@@ -295,22 +309,26 @@ export default function AppView() { title={releaseName} titleSize="md" subtitle={ - appDetails?.availablePackageRef ? ( + selectedAvailablePkg?.availablePackageRef ? ( from package{" "} - {appDetails.displayName} + {selectedAvailablePkg.displayName} ) : ( from an unknown package ) } - plugin={app?.availablePackageRef?.plugin} + plugin={selectedInstalledPkg?.availablePackageRef?.plugin} icon={icon} - buttons={getButtons(app, error, revision)} + buttons={getButtons(selectedInstalledPkg, error, revision)} /> {error && (error.constructor === FetchWarning ? ( @@ -324,12 +342,15 @@ export default function AppView() { ) : ( An error occurred: {error["message"]} ))} - {!app || !app?.status ? ( + {!selectedInstalledPkg || !selectedInstalledPkg?.status ? ( ) : ( - +
@@ -338,14 +359,14 @@ export default function AppView() { deployRefs={deployments} statefulsetRefs={statefulsets} daemonsetRefs={daemonsets} - info={app} + info={selectedInstalledPkg} />
- +
diff --git a/dashboard/src/components/Catalog/Catalog.test.tsx b/dashboard/src/components/Catalog/Catalog.test.tsx index 2db48434fdc..dde0b6b836f 100644 --- a/dashboard/src/components/Catalog/Catalog.test.tsx +++ b/dashboard/src/components/Catalog/Catalog.test.tsx @@ -8,6 +8,7 @@ import InfoCard from "components/InfoCard/InfoCard"; import Alert from "components/js/Alert"; import LoadingWrapper from "components/LoadingWrapper"; import { AvailablePackageSummary, Context } from "gen/kubeappsapis/core/packages/v1alpha1/packages"; +import { PackageRepositorySummary } from "gen/kubeappsapis/core/packages/v1alpha1/repositories"; import { Plugin } from "gen/kubeappsapis/core/plugins/v1alpha1/plugins"; import { createMemoryHistory } from "history"; import React from "react"; @@ -17,14 +18,9 @@ import * as ReactRouter from "react-router"; import { MemoryRouter, Route, Router } from "react-router-dom"; import { IConfigState } from "reducers/config"; import { IOperatorsState } from "reducers/operators"; -import { IAppRepositoryState } from "reducers/repos"; +import { IPackageRepositoryState } from "reducers/repos"; import { getStore, initialState, mountWrapper } from "shared/specs/mountWrapper"; -import { - IAppRepository, - IPackageState, - IClusterServiceVersion, - IStoreState, -} from "../../shared/types"; +import { IClusterServiceVersion, IPackageState, IStoreState } from "../../shared/types"; import SearchFilter from "../SearchFilter/SearchFilter"; import Catalog, { filterNames } from "./Catalog"; import CatalogItems from "./CatalogItems"; @@ -99,7 +95,7 @@ const csv = { const defaultState = { packages: defaultPackageState, operators: { csvs: [] } as Partial, - repos: { repos: [] } as Partial, + repos: { repos: [] } as Partial, config: { kubeappsCluster: defaultProps.cluster, kubeappsNamespace: defaultProps.kubeappsNamespace, @@ -624,7 +620,7 @@ describe("filters by package repository", () => { spyOnUseDispatch = jest.spyOn(ReactRedux, "useDispatch").mockReturnValue(mockDispatch); // Can't just assign a mock fn to actions.repos.fetchRepos because it is (correctly) exported // as a const fn. - fetchRepos = jest.spyOn(actions.repos, "fetchRepos").mockImplementation(() => { + fetchRepos = jest.spyOn(actions.repos, "fetchRepoSummaries").mockImplementation(() => { return jest.fn(); }); }); @@ -665,7 +661,7 @@ describe("filters by package repository", () => { const wrapper = mountWrapper( getStore({ ...populatedState, - repos: { repos: [{ metadata: { name: "foo" } } as IAppRepository] }, + repos: { repos: [{ name: "foo" } as PackageRepositorySummary] }, }), @@ -693,7 +689,7 @@ describe("filters by package repository", () => { const wrapper = mountWrapper( getStore({ ...populatedState, - repos: { repos: [{ metadata: { name: "foo" } } as IAppRepository] }, + repos: { repos: [{ name: "foo" } as PackageRepositorySummary] }, }), @@ -721,7 +717,7 @@ describe("filters by package repository", () => { mountWrapper( getStore({ ...populatedState, - repos: { repos: [{ metadata: { name: "foo" } } as IAppRepository] }, + repos: { repos: [{ name: "foo" } as PackageRepositorySummary] }, }), { ); // Called without the boolean `true` option to additionally fetch global repos. - expect(fetchRepos).toHaveBeenCalledWith(initialState.config.globalReposNamespace); + expect(fetchRepos).toHaveBeenCalledWith(""); }); it("fetches from the global repos namespace for other clusters", () => { mountWrapper( getStore({ ...populatedState, - repos: { repos: [{ metadata: { name: "foo" } } as IAppRepository] }, + repos: { repos: [{ name: "foo" } as PackageRepositorySummary] }, }), @@ -752,7 +748,7 @@ describe("filters by package repository", () => { ); // Only the global repos should have been fetched. - expect(fetchRepos).toHaveBeenCalledWith(initialState.config.globalReposNamespace); + expect(fetchRepos).toHaveBeenCalledWith(""); }); }); diff --git a/dashboard/src/components/Catalog/Catalog.tsx b/dashboard/src/components/Catalog/Catalog.tsx index defcd0d2df7..1e6fc23b70a 100644 --- a/dashboard/src/components/Catalog/Catalog.tsx +++ b/dashboard/src/components/Catalog/Catalog.tsx @@ -201,7 +201,11 @@ export default function Catalog() { pushFilters(filters); }; - const allRepos = uniq(repos.map(c => c.metadata.name)); + const allRepos = uniq( + repos + .filter(r => !r.namespaceScoped || r.packageRepoRef?.context?.namespace === namespace) + .map(r => r.name), + ); const allProviders = uniq(csvs.map(c => c.spec.provider.name)); const allCategories = uniq( categories @@ -212,13 +216,13 @@ export default function Catalog() { // We do not currently support package repositories on additional clusters. const supportedCluster = cluster === kubeappsCluster; useEffect(() => { - if (!supportedCluster || namespace === globalReposNamespace) { - // Global namespace or other cluster, show global repos only - dispatch(actions.repos.fetchRepos(globalReposNamespace)); + if (!namespace || !supportedCluster || namespace === globalReposNamespace) { + // All Namespaces. Global namespace or other cluster, show global repos only + dispatch(actions.repos.fetchRepoSummaries("")); return () => {}; } // In other case, fetch global and namespace repos - dispatch(actions.repos.fetchRepos(namespace, true)); + dispatch(actions.repos.fetchRepoSummaries(namespace, true)); return () => {}; }, [dispatch, supportedCluster, namespace, globalReposNamespace]); @@ -377,7 +381,7 @@ export default function Catalog() { Manage your Package Repositories in Kubeapps by visiting the Package repositories configuration page.

- + Manage Package Repositories

diff --git a/dashboard/src/components/Config/AppRepoList/AppRepoForm.test.tsx b/dashboard/src/components/Config/AppRepoList/AppRepoForm.test.tsx deleted file mode 100644 index 93beb746ad5..00000000000 --- a/dashboard/src/components/Config/AppRepoList/AppRepoForm.test.tsx +++ /dev/null @@ -1,699 +0,0 @@ -// Copyright 2020-2022 the Kubeapps contributors. -// SPDX-License-Identifier: Apache-2.0 - -import { CdsButton } from "@cds/react/button"; -import actions from "actions"; -import Alert from "components/js/Alert"; -import { act } from "react-dom/test-utils"; -import * as ReactRedux from "react-redux"; -import { defaultStore, getStore, mountWrapper } from "shared/specs/mountWrapper"; -import { IPkgRepoFormData } from "shared/types"; -import { PackageRepositoryAuth_PackageRepositoryAuthType } from "gen/kubeappsapis/core/packages/v1alpha1/repositories"; -import Secret from "shared/Secret"; -import { AppRepoForm } from "./AppRepoForm"; - -const defaultProps = { - onSubmit: jest.fn(), - namespace: "default", - kubeappsNamespace: "kubeapps", -}; - -const repoData = { - plugin: undefined, - name: undefined, - type: undefined, - url: undefined, - authHeader: "", - authMethod: - PackageRepositoryAuth_PackageRepositoryAuthType.PACKAGE_REPOSITORY_AUTH_TYPE_UNSPECIFIED, - basicAuth: { - password: "", - username: "", - }, - customCA: "", - customDetails: { - dockerRegistrySecrets: [""], - ociRepositories: [], - performValidation: undefined, - filterRules: undefined, - }, - description: "", - dockerRegCreds: { - password: "", - username: "", - email: "", - server: "", - }, - interval: "10m", - passCredentials: false, - secretAuthName: "", - secretTLSName: "", - skipTLS: false, - opaqueCreds: { - data: {}, - }, - sshCreds: { - knownHosts: "", - privateKey: "", - }, - tlsCertKey: { - cert: "", - key: "", - }, -} as unknown as IPkgRepoFormData; - -let spyOnUseDispatch: jest.SpyInstance; -const kubeaActions = { ...actions.kube }; -beforeEach(() => { - actions.repos = { - ...actions.repos, - validateRepo: jest.fn().mockReturnValue(true), - }; - const mockDispatch = jest.fn(r => r); - spyOnUseDispatch = jest.spyOn(ReactRedux, "useDispatch").mockReturnValue(mockDispatch); - Secret.getDockerConfigSecretNames = jest.fn(() => Promise.resolve([])); -}); - -afterEach(() => { - actions.kube = { ...kubeaActions }; - spyOnUseDispatch.mockRestore(); -}); - -// TODO(agamez): re-enable this test once we we add a dropdown to select the secret name -// eslint-disable-next-line jest/no-commented-out-tests -// it("fetches secret names", async () => { -// await act(async () => { -// mountWrapper(defaultStore, ); -// }); -// expect(Secret.getDockerConfigSecretNames).toHaveBeenCalledWith( -// "default-cluster", -// defaultProps.namespace, -// ); -// }); - -it("disables the submit button while fetching", async () => { - let wrapper: any; - await act(async () => { - wrapper = mountWrapper( - getStore({ repos: { validating: true } }), - , - ); - }); - expect( - wrapper - .find(CdsButton) - .filterWhere((b: any) => b.html().includes("Validating")) - .prop("disabled"), - ).toBe(true); -}); - -it("submit button can not be fired more than once", async () => { - const onSubmit = jest.fn().mockReturnValue(true); - const onAfterInstall = jest.fn().mockReturnValue(true); - let wrapper: any; - await act(async () => { - wrapper = mountWrapper( - defaultStore, - , - ); - }); - const installButton = wrapper - .find(CdsButton) - .filterWhere((b: any) => b.html().includes("Install Repo")); - await act(async () => { - Promise.all([ - installButton.simulate("submit"), - installButton.simulate("submit"), - installButton.simulate("submit"), - ]); - }); - wrapper.update(); - expect(onSubmit.mock.calls.length).toBe(1); -}); - -it("should show a validation error", async () => { - let wrapper: any; - await act(async () => { - wrapper = mountWrapper( - getStore({ repos: { errors: { validate: new Error("Boom!") } } }), - , - ); - }); - expect(wrapper.find(Alert).text()).toContain("Boom!"); -}); - -it("shows an error updating a repo", async () => { - let wrapper: any; - await act(async () => { - wrapper = mountWrapper( - getStore({ repos: { errors: { update: new Error("boom!") } } }), - , - ); - }); - expect(wrapper.find(Alert)).toIncludeText("boom!"); -}); - -it("should call the install method when the validation success", async () => { - const validateRepo = jest.fn().mockReturnValue(true); - const install = jest.fn().mockReturnValue(true); - actions.repos = { - ...actions.repos, - validateRepo, - }; - - let wrapper: any; - await act(async () => { - wrapper = mountWrapper(defaultStore, ); - }); - const form = wrapper.find("form"); - await act(async () => { - await (form.prop("onSubmit") as (e: any) => Promise)({ preventDefault: jest.fn() }); - }); - wrapper.update(); - expect(install).toHaveBeenCalled(); -}); - -it("should call the install method with OCI information", async () => { - const validateRepo = jest.fn().mockReturnValue(true); - const install = jest.fn().mockReturnValue(true); - actions.repos = { - ...actions.repos, - validateRepo, - }; - let wrapper: any; - await act(async () => { - wrapper = mountWrapper(defaultStore, ); - }); - wrapper.find("#kubeapps-repo-name").simulate("change", { target: { value: "my-oci-repo" } }); - wrapper.find("#kubeapps-repo-url").simulate("change", { target: { value: "oci.repo" } }); - wrapper.find("#kubeapps-repo-type-oci").simulate("change"); - wrapper - .find("#kubeapps-oci-repositories") - .simulate("change", { target: { value: "apache, jenkins" } }); - const form = wrapper.find("form"); - await act(async () => { - await (form.prop("onSubmit") as (e: any) => Promise)({ preventDefault: jest.fn() }); - }); - wrapper.update(); - expect(install).toHaveBeenCalledWith({ - ...repoData, - customDetails: { - ...repoData.customDetails, - performValidation: true, - ociRepositories: ["apache", "jenkins"], - }, - name: "my-oci-repo", - type: "oci", - url: "https://oci.repo", - plugin: { name: "helm.packages", version: "v1alpha1" }, - }); -}); - -it("should call the install skipping TLS verification", async () => { - const validateRepo = jest.fn().mockReturnValue(true); - const install = jest.fn().mockReturnValue(true); - actions.repos = { - ...actions.repos, - validateRepo, - }; - let wrapper: any; - await act(async () => { - wrapper = mountWrapper(defaultStore, ); - }); - wrapper.find("#kubeapps-repo-name").simulate("change", { target: { value: "my-repo" } }); - wrapper.find("#kubeapps-repo-url").simulate("change", { target: { value: "helm.repo" } }); - wrapper.find("#kubeapps-repo-type-helm").simulate("change"); - wrapper.find("#kubeapps-repo-skip-tls").simulate("change"); - const form = wrapper.find("form"); - await act(async () => { - await (form.prop("onSubmit") as (e: any) => Promise)({ preventDefault: jest.fn() }); - }); - wrapper.update(); - expect(install).toHaveBeenCalledWith({ - ...repoData, - customDetails: { - ...repoData.customDetails, - performValidation: true, - }, - name: "my-repo", - type: "helm", - url: "https://helm.repo", - plugin: { name: "helm.packages", version: "v1alpha1" }, - skipTLS: true, - }); -}); - -it("should call the install passing credentials", async () => { - const validateRepo = jest.fn().mockReturnValue(true); - const install = jest.fn().mockReturnValue(true); - actions.repos = { - ...actions.repos, - validateRepo, - }; - let wrapper: any; - await act(async () => { - wrapper = mountWrapper(defaultStore, ); - }); - wrapper.find("#kubeapps-repo-name").simulate("change", { target: { value: "my-repo" } }); - wrapper.find("#kubeapps-repo-url").simulate("change", { target: { value: "helm.repo" } }); - wrapper.find("#kubeapps-repo-type-helm").simulate("change"); - wrapper.find("#kubeapps-repo-pass-credentials").simulate("change"); - const form = wrapper.find("form"); - await act(async () => { - await (form.prop("onSubmit") as (e: any) => Promise)({ preventDefault: jest.fn() }); - }); - wrapper.update(); - expect(install).toHaveBeenCalledWith({ - ...repoData, - customDetails: { - ...repoData.customDetails, - performValidation: true, - }, - name: "my-repo", - type: "helm", - url: "https://helm.repo", - plugin: { name: "helm.packages", version: "v1alpha1" }, - passCredentials: true, - }); -}); - -describe("when using a filter", () => { - it("should call the install method with a filter", async () => { - const install = jest.fn().mockReturnValue(true); - let wrapper: any; - await act(async () => { - wrapper = mountWrapper(defaultStore, ); - }); - wrapper.find("#kubeapps-repo-name").simulate("change", { target: { value: "my-repo" } }); - wrapper - .find("#kubeapps-repo-url") - .simulate("change", { target: { value: "https://helm.repo" } }); - wrapper.find("#kubeapps-repo-type-helm").simulate("change"); - wrapper - .find("#kubeapps-repo-filter-repositories") - .simulate("change", { target: { value: "nginx, wordpress" } }); - const form = wrapper.find("form"); - await act(async () => { - await (form.prop("onSubmit") as (e: any) => Promise)({ preventDefault: jest.fn() }); - }); - wrapper.update(); - expect(install).toHaveBeenCalledWith({ - ...repoData, - customDetails: { - ...repoData.customDetails, - performValidation: true, - filterRule: { - jq: ".name == $var0 or .name == $var1", - variables: { $var0: "nginx", $var1: "wordpress" }, - }, - }, - name: "my-repo", - type: "helm", - url: "https://helm.repo", - plugin: { name: "helm.packages", version: "v1alpha1" }, - }); - }); - - it("should call the install method with a filter excluding a regex", async () => { - const install = jest.fn().mockReturnValue(true); - let wrapper: any; - await act(async () => { - wrapper = mountWrapper(defaultStore, ); - }); - wrapper.find("#kubeapps-repo-name").simulate("change", { target: { value: "my-repo" } }); - wrapper - .find("#kubeapps-repo-url") - .simulate("change", { target: { value: "https://helm.repo" } }); - wrapper.find("#kubeapps-repo-type-helm").simulate("change"); - wrapper - .find("#kubeapps-repo-filter-repositories") - .simulate("change", { target: { value: "nginx" } }); - wrapper.find('input[type="checkbox"]').at(0).simulate("change"); - wrapper.find('input[type="checkbox"]').at(1).simulate("change"); - const form = wrapper.find("form"); - await act(async () => { - await (form.prop("onSubmit") as (e: any) => Promise)({ preventDefault: jest.fn() }); - }); - wrapper.update(); - expect(install).toHaveBeenCalledWith({ - ...repoData, - customDetails: { - ...repoData.customDetails, - performValidation: true, - filterRule: { jq: ".name | test($var) | not", variables: { $var: "nginx" } }, - }, - name: "my-repo", - type: "helm", - url: "https://helm.repo", - plugin: { name: "helm.packages", version: "v1alpha1" }, - }); - }); - - it("ignore the filter for the OCI case", async () => { - const install = jest.fn().mockReturnValue(true); - let wrapper: any; - await act(async () => { - wrapper = mountWrapper(defaultStore, ); - }); - wrapper.find("#kubeapps-repo-name").simulate("change", { target: { value: "my-repo" } }); - wrapper - .find("#kubeapps-repo-url") - .simulate("change", { target: { value: "https://oci.repo" } }); - wrapper - .find("#kubeapps-repo-filter-repositories") - .simulate("change", { target: { value: "nginx, wordpress" } }); - wrapper.find("#kubeapps-repo-type-oci").simulate("change"); - const form = wrapper.find("form"); - await act(async () => { - await (form.prop("onSubmit") as (e: any) => Promise)({ preventDefault: jest.fn() }); - }); - wrapper.update(); - expect(install).toHaveBeenCalledWith({ - ...repoData, - customDetails: { - ...repoData.customDetails, - performValidation: true, - }, - name: "my-repo", - type: "oci", - url: "https://oci.repo", - plugin: { name: "helm.packages", version: "v1alpha1" }, - }); - }); -}); - -describe("when using a description", () => { - it("should call the install method with a description", async () => { - const install = jest.fn().mockReturnValue(true); - let wrapper: any; - await act(async () => { - wrapper = mountWrapper(defaultStore, ); - }); - wrapper.find("#kubeapps-repo-name").simulate("change", { target: { value: "my-repo" } }); - wrapper.find("#kubeapps-repo-type-helm").simulate("change"); - wrapper - .find("#kubeapps-repo-url") - .simulate("change", { target: { value: "https://helm.repo" } }); - wrapper - .find("#kubeapps-repo-description") - .simulate("change", { target: { value: "description test" } }); - const form = wrapper.find("form"); - await act(async () => { - await (form.prop("onSubmit") as (e: any) => Promise)({ preventDefault: jest.fn() }); - }); - wrapper.update(); - expect(install).toHaveBeenCalledWith({ - ...repoData, - customDetails: { - ...repoData.customDetails, - performValidation: true, - }, - name: "my-repo", - type: "helm", - url: "https://helm.repo", - description: "description test", - plugin: { name: "helm.packages", version: "v1alpha1" }, - }); - }); -}); - -it("should not show the list of OCI repositories if using a Helm repo (default)", async () => { - let wrapper: any; - await act(async () => { - wrapper = mountWrapper(defaultStore, ); - }); - wrapper.find("#kubeapps-repo-type-helm").simulate("change"); - expect(wrapper.find("#kubeapps-oci-repositories")).not.toExist(); -}); - -// TODO(agamez): fix those tests in the upcoming PR -// eslint-disable-next-line jest/no-commented-out-tests -// describe("when the repository info is already populated", () => { -// TODO(agamez): fix those tests in the upcoming PR -// eslint-disable-next-line jest/no-commented-out-tests -// eslint-disable-next-line jest/no-commented-out-tests -// it("should parse the existing name", async () => { -// const repo = { metadata: { name: "foo" } } as any; -// let wrapper: any; -// await act(async () => { -// wrapper = await mountWrapper( -// defaultStore, -// , -// ); -// }); -// await waitFor(() => { -// wrapper.update(); -// expect(wrapper.find("#kubeapps-repo-name").prop("value")).toBe("foo"); -// }); -// // It should also deactivate the name input if it's already been set -// expect(wrapper.find("#kubeapps-repo-name").prop("disabled")).toBe(true); -// }); - -// TODO(agamez): fix those tests in the upcoming PR -// eslint-disable-next-line jest/no-commented-out-tests -// it("should parse the existing url", async () => { -// const repo = { metadata: { name: "foo" }, spec: { url: "http://repo" } } as any; -// let wrapper: any; -// await act(async () => { -// wrapper = mountWrapper(defaultStore, ); -// }); -// await waitFor(() => { -// wrapper.update(); -// expect(wrapper.find("#kubeapps-repo-url").prop("value")).toBe("http://repo"); -// }); -// }); - -// TODO(agamez): fix those tests in the upcoming PR -// eslint-disable-next-line jest/no-commented-out-tests -// describe("when there is a secret associated to the repo", () => { -// TODO(agamez): fix those tests in the upcoming PR -// eslint-disable-next-line jest/no-commented-out-tests -// it("should parse the existing CA cert", async () => { -// const repo = { -// metadata: { name: "foo", namespace: "default" }, -// spec: { auth: { customCA: { secretKeyRef: { name: "bar" } } } }, -// } as any; -// const secret = { data: { "ca.crt": "Zm9v" } } as any; -// AppRepository.getSecretForRepo = jest.fn(() => secret); - -// let wrapper: any; -// act(() => { -// wrapper = mountWrapper( -// defaultStore, -// , -// ); -// }); - -// await waitFor(() => { -// expect(AppRepository.getSecretForRepo).toHaveBeenCalledWith( -// "default-cluster", -// "default", -// "foo", -// ); -// }); -// wrapper.update(); -// expect(wrapper.find("#kubeapps-repo-custom-ca").prop("value")).toBe("foo"); -// }); - -// TODO(agamez): fix those tests in the upcoming PR -// eslint-disable-next-line jest/no-commented-out-tests -// it("should parse the existing auth header", async () => { -// const repo = { -// metadata: { name: "foo", namespace: "default" }, -// spec: { auth: { header: { secretKeyRef: { name: "bar" } } } }, -// } as any; -// const secret = { data: { authorizationHeader: "Zm9v" } } as any; -// AppRepository.getSecretForRepo = jest.fn(() => secret); - -// let wrapper: any; -// act(() => { -// wrapper = mountWrapper( -// defaultStore, -// , -// ); -// }); - -// await waitFor(() => { -// expect(AppRepository.getSecretForRepo).toHaveBeenCalledWith( -// "default-cluster", -// "default", -// "foo", -// ); -// }); -// wrapper.update(); -// expect(wrapper.find("#kubeapps-repo-custom-header").prop("value")).toBe("foo"); -// }); - -// TODO(agamez): fix those tests in the upcoming PR -// eslint-disable-next-line jest/no-commented-out-tests -// it("should parse the existing basic auth", async () => { -// const repo = { -// metadata: { name: "foo", namespace: "default" }, -// spec: { auth: { header: { secretKeyRef: { name: "bar" } } } }, -// } as any; -// const secret = { data: { authorizationHeader: "QmFzaWMgWm05dk9tSmhjZz09" } } as any; -// AppRepository.getSecretForRepo = jest.fn(() => secret); - -// let wrapper: any; -// act(() => { -// wrapper = mountWrapper( -// defaultStore, -// , -// ); -// }); - -// await waitFor(() => { -// wrapper.update(); -// expect(wrapper.find("#kubeapps-repo-username").prop("value")).toBe("foo"); -// }); -// expect(wrapper.find("#kubeapps-repo-password").prop("value")).toBe("bar"); -// }); - -// TODO(agamez): fix those tests in the upcoming PR -// eslint-disable-next-line jest/no-commented-out-tests -// it("should parse the existing type", async () => { -// const repo = { metadata: { name: "foo" }, spec: { type: "oci" } } as any; -// let wrapper: any; -// await act(async () => { -// wrapper = mountWrapper( -// defaultStore, -// , -// ); -// }); -// await waitFor(() => { -// wrapper.update(); -// expect(wrapper.find("#kubeapps-repo-type-oci")).toBeChecked(); -// }); -// expect(wrapper.find("#kubeapps-oci-repositories")).toExist(); -// }); - -// TODO(agamez): fix those tests in the upcoming PR -// eslint-disable-next-line jest/no-commented-out-tests -// it("should parse the existing skip tls config", async () => { -// const repo = { metadata: { name: "foo" }, spec: { tlsInsecureSkipVerify: true } } as any; -// let wrapper: any; -// await act(async () => { -// wrapper = mountWrapper( -// defaultStore, -// , -// ); -// }); -// expect(wrapper.find("#kubeapps-repo-skip-tls")).toBeChecked(); -// }); - -// TODO(agamez): fix those tests in the upcoming PR -// eslint-disable-next-line jest/no-commented-out-tests -// it("should parse the existing pass credentials config", async () => { -// const repo = { metadata: { name: "foo" }, spec: { passCredentials: true } } as any; -// let wrapper: any; -// await act(async () => { -// wrapper = mountWrapper( -// defaultStore, -// , -// ); -// }); -// expect(wrapper.find("#kubeapps-repo-pass-credentials")).toBeChecked(); -// }); - -// TODO(agamez): fix those tests in the upcoming PR -// eslint-disable-next-line jest/no-commented-out-tests -// it("should parse a bearer token", async () => { -// const repo = { -// metadata: { name: "foo", namespace: "default" }, -// spec: { auth: { header: { secretKeyRef: { name: "bar" } } } }, -// } as any; -// const secret = { data: { authorizationHeader: "QmVhcmVyIGZvbw==" } } as any; -// AppRepository.getSecretForRepo = jest.fn(() => secret); - -// let wrapper: any; -// act(() => { -// wrapper = mountWrapper( -// defaultStore, -// , -// ); -// }); - -// await waitFor(() => { -// wrapper.update(); -// expect(wrapper.find("#kubeapps-repo-token").prop("value")).toBe("foo"); -// }); -// }); - -// TODO(agamez): fix those tests in the upcoming PR -// eslint-disable-next-line jest/no-commented-out-tests -// it("should select a docker secret as auth mechanism", async () => { -// const repo = { -// metadata: { name: "foo", namespace: "default" }, -// spec: { auth: { header: { secretKeyRef: { name: "bar" } } } }, -// } as any; -// const secret = { data: { ".dockerconfigjson": "QmVhcmVyIGZvbw==" } } as any; -// AppRepository.getSecretForRepo = jest.fn(() => secret); - -// let wrapper: any; -// act(() => { -// wrapper = mountWrapper( -// defaultStore, -// , -// ); -// }); - -// await waitFor(() => { -// wrapper.update(); -// expect(wrapper.find("#kubeapps-repo-auth-method-registry")).toBeChecked(); -// }); -// }); - -// TODO(agamez): fix those tests in the upcoming PR -// eslint-disable-next-line jest/no-commented-out-tests -// it("should parse the existing filter (simple)", async () => { -// const repo = { -// metadata: { name: "foo" }, -// spec: { -// type: "helm", -// filterRule: { -// jq: ".name == $var0 or .name == $var1", -// variables: { $var0: "nginx", $var1: "wordpress" }, -// }, -// }, -// } as any; -// let wrapper: any; -// await act(async () => { -// wrapper = mountWrapper( -// defaultStore, -// , -// ); -// }); -// await waitFor(() => { -// wrapper.update(); -// expect(wrapper.find("textarea").at(0).prop("value")).toBe("nginx, wordpress"); -// }); -// expect(wrapper.find('input[type="checkbox"]').at(0)).not.toBeChecked(); -// expect(wrapper.find('input[type="checkbox"]').at(1)).not.toBeChecked(); -// }); - -// TODO(agamez): fix those tests in the upcoming PR -// eslint-disable-next-line jest/no-commented-out-tests -// it("should parse the existing filter (negated regex)", async () => { -// const repo = { -// metadata: { name: "foo" }, -// spec: { -// type: "helm", -// filterRule: { jq: ".name | test($var) | not", variables: { $var: "nginx" } }, -// }, -// } as any; -// let wrapper: any; -// await act(async () => { -// wrapper = mountWrapper( -// defaultStore, -// , -// ); -// }); -// await waitFor(() => { -// wrapper.update(); -// expect(wrapper.find("textarea").at(0).prop("value")).toBe("nginx"); -// }); -// expect(wrapper.find('input[type="checkbox"]').at(0)).toBeChecked(); -// expect(wrapper.find('input[type="checkbox"]').at(1)).toBeChecked(); -// }); -// }); -// }); diff --git a/dashboard/src/components/Config/AppRepoList/AppRepoForm.tsx b/dashboard/src/components/Config/AppRepoList/AppRepoForm.tsx deleted file mode 100644 index 4ac02ed3c62..00000000000 --- a/dashboard/src/components/Config/AppRepoList/AppRepoForm.tsx +++ /dev/null @@ -1,948 +0,0 @@ -// Copyright 2019-2022 the Kubeapps contributors. -// SPDX-License-Identifier: Apache-2.0 - -import { - CdsAccordion, - CdsAccordionContent, - CdsAccordionHeader, - CdsAccordionPanel, -} from "@cds/react/accordion"; -import { CdsButton } from "@cds/react/button"; -import { CdsCheckbox } from "@cds/react/checkbox"; -import { CdsControlMessage, CdsFormGroup } from "@cds/react/forms"; -import { CdsInput } from "@cds/react/input"; -import { CdsRadio, CdsRadioGroup } from "@cds/react/radio"; -import { CdsTextarea } from "@cds/react/textarea"; -import actions from "actions"; -import Alert from "components/js/Alert"; -import { - DockerCredentials, - PackageRepositoryAuth_PackageRepositoryAuthType, - UsernamePassword, -} from "gen/kubeappsapis/core/packages/v1alpha1/repositories"; -import { Plugin } from "gen/kubeappsapis/core/plugins/v1alpha1/plugins"; -import { RepositoryCustomDetails } from "gen/kubeappsapis/plugins/helm/packages/v1alpha1/helm"; -import { useEffect, useRef, useState } from "react"; -import { useDispatch, useSelector } from "react-redux"; -import { Action } from "redux"; -import { ThunkDispatch } from "redux-thunk"; -import { toFilterRule, toParams } from "shared/jq"; -import { - IAppRepository, - IPkgRepoFormData, - IPkgRepositoryFilter, - IStoreState, - RepositoryStorageTypes, -} from "shared/types"; -import { getPluginByName, getPluginPackageName, PluginNames } from "shared/utils"; -import "./AppRepoForm.css"; - -interface IAppRepoFormProps { - onSubmit: (data: IPkgRepoFormData) => Promise; - onAfterInstall?: () => void; - namespace: string; - kubeappsNamespace: string; - packageRepoRef?: IAppRepository; -} - -export function AppRepoForm(props: IAppRepoFormProps) { - const { - onSubmit, - onAfterInstall, - namespace, - kubeappsNamespace, - packageRepoRef: selectedPkgRepo, - } = props; - const isInstallingRef = useRef(false); - const dispatch: ThunkDispatch = useDispatch(); - - const { - repos: { - repo, - errors: { create: createError, update: updateError, validate: validationError }, - validating, - }, - clusters: { currentCluster }, - } = useSelector((state: IStoreState) => state); - - // -- Auth-related variables -- - - // Auth type of the package repository - const [authMethod, setAuthMethod] = useState( - PackageRepositoryAuth_PackageRepositoryAuthType.PACKAGE_REPOSITORY_AUTH_TYPE_UNSPECIFIED, - ); - - // PACKAGE_REPOSITORY_AUTH_TYPE_AUTHORIZATION_HEADER - const [authCustomHeader, setAuthCustomHeader] = useState(""); - - // PACKAGE_REPOSITORY_AUTH_TYPE_BASIC_AUTH - const [basicPassword, setBasicPassword] = useState(""); - const [basicUser, setBasicUser] = useState(""); - - // PACKAGE_REPOSITORY_AUTH_TYPE_BEARER - const [bearerToken, setBearerToken] = useState(""); - - // PACKAGE_REPOSITORY_AUTH_TYPE_DOCKER_CONFIG_JSON - const [secretEmail, setSecretEmail] = useState(""); - const [secretUser, setSecretUser] = useState(""); - const [secretPassword, setSecretPassword] = useState(""); - const [secretServer, setSecretServer] = useState(""); - - // rest of the package repo form variables - - const initialInterval = "10m"; - - const [customCA, setCustomCA] = useState(""); - const [description, setDescription] = useState(""); - const [filterExclude, setFilterExclude] = useState(false); - const [filterNames, setFilterNames] = useState(""); - const [filterRegex, setFilterRegex] = useState(false); - const [interval, setInterval] = useState(initialInterval); - const [name, setName] = useState(""); - const [ociRepositories, setOCIRepositories] = useState(""); - const [passCredentials, setPassCredentials] = useState(!!repo?.spec?.passCredentials); - const [performValidation, setPerformValidation] = useState(true); - // TODO(agamez): initially hardcoded to the Helm plugin - const [plugin, setPlugin] = useState(getPluginByName(PluginNames.PACKAGES_HELM) as Plugin); - // const [plugin, setPlugin] = useState({} as Plugin); - const [skipTLS, setSkipTLS] = useState(!!repo?.spec?.tlsInsecureSkipVerify); - const [type, setType] = useState( - RepositoryStorageTypes.PACKAGE_REPOSITORY_STORAGE_HELM.toString(), - ); - const [url, setURL] = useState(""); - - // initial state (collapsed or not) of each accordion tab - const [accordion, setAccordion] = useState([true, false, false, false]); - - const toggleAccordion = (section: number) => { - const items = [...accordion]; - items[section] = !items[section]; - setAccordion(items); - }; - - useEffect(() => { - if (selectedPkgRepo) { - dispatch( - actions.repos.fetchRepo( - currentCluster, - selectedPkgRepo?.metadata?.namespace, - selectedPkgRepo?.metadata?.name, - ), - ); - } - }, [dispatch, currentCluster, selectedPkgRepo]); - - useEffect(() => { - if (repo) { - // populate state properties from the incoming repo - setName(repo?.metadata?.name); - setURL(repo?.spec?.url || ""); - setType(repo?.spec?.type || ""); - setDescription(repo?.spec?.description || ""); - setOCIRepositories(repo?.spec?.ociRepositories?.join(", ") || ""); - setSkipTLS(!!repo?.spec?.tlsInsecureSkipVerify); - setPassCredentials(!!repo?.spec?.passCredentials); - if (repo?.spec?.filterRule?.jq) { - const { names, regex, exclude } = toParams(repo?.spec.filterRule); - setFilterRegex(regex); - setFilterExclude(exclude); - setFilterNames(names); - } - } - }, [repo, namespace, currentCluster, dispatch]); - - const handleInstallClick = async (e: React.FormEvent) => { - e.preventDefault(); - install(); - }; - - const install = async () => { - if (isInstallingRef.current) { - // Another installation is ongoing - return; - } - isInstallingRef.current = true; - - // send the proper header depending on the auth method - let finalHeader = ""; - switch (authMethod) { - case PackageRepositoryAuth_PackageRepositoryAuthType.PACKAGE_REPOSITORY_AUTH_TYPE_AUTHORIZATION_HEADER: - finalHeader = authCustomHeader; - break; - case PackageRepositoryAuth_PackageRepositoryAuthType.PACKAGE_REPOSITORY_AUTH_TYPE_BEARER: - finalHeader = `Bearer ${bearerToken}`; - break; - } - - // create an array from the (trimmed) comma separated string - const ociRepoList = ociRepositories.length - ? ociRepositories?.split(",").map(r => r.trim()) - : []; - - // If the scheme is not specified, assume HTTPS. This is common for OCI registries - // unless using the kapp plugin, which explicitly should not include https:// protocol prefix - let finalURL = url; - if (plugin?.name !== PluginNames.PACKAGES_KAPP && !url?.startsWith("http")) { - finalURL = `https://${url}`; - } - - // build the IAppRepositoryFilter object based on the filter names plus the regex and exclude options - let filter: IPkgRepositoryFilter | undefined; - if (type === RepositoryStorageTypes.PACKAGE_REPOSITORY_STORAGE_HELM && filterNames !== "") { - filter = toFilterRule(filterNames, filterRegex, filterExclude); - } - - const success = await onSubmit({ - authHeader: finalHeader, - authMethod, - basicAuth: { - password: basicPassword, - username: basicUser, - } as UsernamePassword, - customCA, - customDetails: { - ociRepositories: ociRepoList, - performValidation, - filterRule: filter, - dockerRegistrySecrets: [""], - } as RepositoryCustomDetails, - description, - dockerRegCreds: { - username: secretUser, - email: secretEmail, - password: secretPassword, - server: secretServer, - } as DockerCredentials, - interval, - name, - passCredentials, - plugin, - secretAuthName: "", - secretTLSName: "", - skipTLS, - type, - url: finalURL, - opaqueCreds: { - data: JSON.parse("{}"), - }, - sshCreds: { - knownHosts: "", - privateKey: "", - }, - tlsCertKey: { - cert: "", - key: "", - }, - } as IPkgRepoFormData); - if (success && onAfterInstall) { - onAfterInstall(); - } - isInstallingRef.current = false; - }; - - const handleNameChange = (e: React.ChangeEvent) => { - setName(e.target.value); - }; - const handleDescriptionChange = (e: React.ChangeEvent) => { - setDescription(e.target.value); - }; - const handleIntervalChange = (e: React.ChangeEvent) => { - setInterval(e.target.value); - }; - const handlePerformValidationChange = (_e: React.ChangeEvent) => { - setPerformValidation(!performValidation); - }; - const handleURLChange = (e: React.ChangeEvent) => { - setURL(e.target.value); - }; - const handleAuthCustomHeaderChange = (e: React.ChangeEvent) => { - setAuthCustomHeader(e.target.value); - }; - const handleAuthBearerTokenChange = (e: React.ChangeEvent) => { - setBearerToken(e.target.value); - }; - const handleCustomCAChange = (e: React.ChangeEvent) => { - setCustomCA(e.target.value); - }; - const handleAuthRadioButtonChange = (e: React.ChangeEvent) => { - setAuthMethod(PackageRepositoryAuth_PackageRepositoryAuthType[e.target.value]); - }; - const handleTypeRadioButtonChange = (e: React.ChangeEvent) => { - setType(e.target.value); - }; - const handlePluginRadioButtonChange = (e: React.ChangeEvent) => { - setPlugin(getPluginByName(e.target.value)); - // set some default values based on the selected plugin - switch (getPluginByName(e.target.value)?.name) { - case PluginNames.PACKAGES_HELM: - setType(RepositoryStorageTypes.PACKAGE_REPOSITORY_STORAGE_HELM); - // helm plugin doesn't allow interval - break; - } - }; - const handleBasicUserChange = (e: React.ChangeEvent) => { - setBasicUser(e.target.value); - }; - const handleBasicPasswordChange = (e: React.ChangeEvent) => { - setBasicPassword(e.target.value); - }; - const handleOCIRepositoriesChange = (e: React.ChangeEvent) => { - setOCIRepositories(e.target.value); - }; - const handleSkipTLSChange = (_e: React.ChangeEvent) => { - setSkipTLS(!skipTLS); - }; - const handlePassCredentialsChange = (_e: React.ChangeEvent) => { - setPassCredentials(!passCredentials); - }; - const handleFilterNamesChange = (e: React.ChangeEvent) => { - setFilterNames(e.target.value); - }; - const handleFilterRegexChange = (_e: React.ChangeEvent) => { - setFilterRegex(!filterRegex); - }; - const handleFilterExcludeChange = (_e: React.ChangeEvent) => { - setFilterExclude(!filterExclude); - }; - const handleAuthSecretUserChange = (e: React.ChangeEvent) => { - setSecretUser(e.target.value); - }; - const handleAuthSecretPasswordChange = (e: React.ChangeEvent) => { - setSecretPassword(e.target.value); - }; - const handleAuthSecretEmailChange = (e: React.ChangeEvent) => { - setSecretEmail(e.target.value); - }; - const handleAuthSecretServerChange = (e: React.ChangeEvent) => { - setSecretServer(e.target.value); - }; - - const parseValidationError = (error: Error) => { - let message = error.message; - try { - const parsedMessage = JSON.parse(message); - if (parsedMessage.code && parsedMessage.message) { - message = `Code: ${parsedMessage.code}. Message: ${parsedMessage.message}`; - } - } catch (e: any) { - // Not a json message - } - return message; - }; - - return ( - <> -

- - - toggleAccordion(0)}> - Basic information - - - - - - - - - - - - - - - - {/* TODO(agamez): these plugin selectors should be loaded - based on the current plugins that are loaded in the cluster */} - - {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} - - Select the plugin to use. - - - - - - {plugin?.name && ( - - {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} - - Select the package storage type. - {(plugin?.name === (PluginNames.PACKAGES_HELM as string) || - plugin?.name === (PluginNames.PACKAGES_FLUX as string)) && ( - <> - - - - - - - - - - )} - - )} - - - - - - toggleAccordion(1)}> - Authentication - - - -
- {/* Begin authentication selection */} - - {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} - - - - - - - - - - - - - - - - - - - - - - - {/* End authentication selection */} - - {/* Begin authentication details */} -
- {/* Begin basic authentication */} - - {/* End basic authentication */} - - {/* Begin http bearer authentication */} - - {/* End http bearer authentication */} - - {/* Begin docker creds authentication */} - - {/* End docker creds authentication */} - - {/* Begin HTTP custom authentication */} - - {/* End HTTP custom authentication */} -
- {/* End authentication details */} -
-
-
-
- -