From b409504d7bfff26ec0c3f9ded32587b02b5e1315 Mon Sep 17 00:00:00 2001 From: Mateusz Szostok Date: Thu, 24 Mar 2022 23:27:11 +0100 Subject: [PATCH] Add Helm template storage backend logic --- cmd/cli/cmd/typeinstance/get.go | 11 +- cmd/cli/docs/capact_typeinstance_get.md | 2 +- cmd/helm-storage-backend/main.go | 13 +- .../templates/deployment.yaml | 10 +- .../charts/helm-storage-backend/values.yaml | 27 +- internal/helm-storage-backend/errors.go | 13 + internal/helm-storage-backend/helm.go | 36 -- .../helm-storage-backend/helm_rel_fetcher.go | 90 +++++ internal/helm-storage-backend/release.go | 60 +--- internal/helm-storage-backend/release_test.go | 313 ++++------------- .../helm-storage-backend/showcase_test.go | 96 ------ internal/helm-storage-backend/template.go | 198 ++++++++++- .../helm-storage-backend/template_test.go | 316 ++++++++++++++++++ .../testdata/sample-chart/Chart.yaml | 24 ++ .../sample-chart/templates/_helpers.tpl | 51 +++ .../sample-chart/templates/service.yaml | 15 + .../testdata/sample-chart/values.yaml | 3 + pkg/hub/client/local/fields.go | 24 +- pkg/hub/client/local/options.go | 6 +- 19 files changed, 837 insertions(+), 471 deletions(-) create mode 100644 internal/helm-storage-backend/errors.go delete mode 100644 internal/helm-storage-backend/helm.go create mode 100644 internal/helm-storage-backend/helm_rel_fetcher.go delete mode 100644 internal/helm-storage-backend/showcase_test.go create mode 100644 internal/helm-storage-backend/template_test.go create mode 100644 internal/helm-storage-backend/testdata/sample-chart/Chart.yaml create mode 100644 internal/helm-storage-backend/testdata/sample-chart/templates/_helpers.tpl create mode 100644 internal/helm-storage-backend/testdata/sample-chart/templates/service.yaml create mode 100644 internal/helm-storage-backend/testdata/sample-chart/values.yaml diff --git a/cmd/cli/cmd/typeinstance/get.go b/cmd/cli/cmd/typeinstance/get.go index 9f00ec29c..ce92dc255 100644 --- a/cmd/cli/cmd/typeinstance/get.go +++ b/cmd/cli/cmd/typeinstance/get.go @@ -8,8 +8,8 @@ import ( "strings" "time" - "capact.io/capact/pkg/hub/client/local" "github.com/fatih/color" + "github.com/spf13/cobra" "capact.io/capact/internal/cli" "capact.io/capact/internal/cli/client" @@ -17,13 +17,12 @@ import ( "capact.io/capact/internal/cli/heredoc" cliprinter "capact.io/capact/internal/cli/printer" gqllocalapi "capact.io/capact/pkg/hub/api/graphql/local" - - "github.com/spf13/cobra" + "capact.io/capact/pkg/hub/client/local" ) const ( yamlFileSeparator = "---" - tableRequiredFields = local.TypeInstanceRootFields | local.TypeInstanceTypeRefFields | local.TypeInstanceUsedByIDField | local.TypeInstanceUsesIDField | local.TypeInstanceLatestResourceVersionField + tableRequiredFields = local.TypeInstanceRootFields | local.TypeInstanceTypeRefFields | local.TypeInstanceUsedByIDField | local.TypeInstanceUsesIDField | local.TypeInstanceLatestResourceVersionVersionField ) // GetOptions is used to store the configuration flags for the Get command. @@ -55,8 +54,8 @@ func NewGet() *cobra.Command { Example: heredoc.WithCLIName(` # Display TypeInstances with IDs 'c49b' and '4793' typeinstance get c49b 4793 - - # Save TypeInstances with IDs 'c49b' and '4793' to file in the update format which later can be submitted for update by: + + # Save TypeInstances with IDs 'c49b' and '4793' to file in the update format which later can be submitted for update by: # typeinstance apply --from-file /tmp/typeinstances.yaml typeinstance get c49b 4793 -oyaml --export > /tmp/typeinstances.yaml `, cli.Name), diff --git a/cmd/cli/docs/capact_typeinstance_get.md b/cmd/cli/docs/capact_typeinstance_get.md index 66655492e..8f993c847 100644 --- a/cmd/cli/docs/capact_typeinstance_get.md +++ b/cmd/cli/docs/capact_typeinstance_get.md @@ -16,7 +16,7 @@ capact typeinstance get [TYPE_INSTANCE_ID...] [flags] # Display TypeInstances with IDs 'c49b' and '4793' capact typeinstance get c49b 4793 -# Save TypeInstances with IDs 'c49b' and '4793' to file in the update format which later can be submitted for update by: +# Save TypeInstances with IDs 'c49b' and '4793' to file in the update format which later can be submitted for update by: # capact typeinstance apply --from-file /tmp/typeinstances.yaml capact typeinstance get c49b 4793 -oyaml --export > /tmp/typeinstances.yaml diff --git a/cmd/helm-storage-backend/main.go b/cmd/helm-storage-backend/main.go index ff3b8d761..b6ca773f0 100644 --- a/cmd/helm-storage-backend/main.go +++ b/cmd/helm-storage-backend/main.go @@ -10,15 +10,16 @@ import ( helm_storage_backend "capact.io/capact/internal/helm-storage-backend" - "capact.io/capact/internal/healthz" - "capact.io/capact/internal/logger" - "capact.io/capact/pkg/hub/api/grpc/storage_backend" "github.com/vrischmann/envconfig" "go.uber.org/zap" "golang.org/x/sync/errgroup" "google.golang.org/grpc" "sigs.k8s.io/controller-runtime/pkg/client/config" "sigs.k8s.io/controller-runtime/pkg/manager/signals" + + "capact.io/capact/internal/healthz" + "capact.io/capact/internal/logger" + "capact.io/capact/pkg/hub/api/grpc/storage_backend" ) // Mode describes the selected handler for the Helm storage backend gRPC server. @@ -67,6 +68,8 @@ func main() { helmCfgFlags := helmCfgFlagsForK8sCfg(k8sCfg) + relFetcher := helm_storage_backend.NewHelmReleaseFetcher(helmCfgFlags) + // setup servers parallelServers := new(errgroup.Group) @@ -74,10 +77,10 @@ func main() { var handler storage_backend.StorageBackendServer switch cfg.Mode { case HelmReleaseMode: - handler, err = helm_storage_backend.NewReleaseHandler(logger, helmCfgFlags) + handler, err = helm_storage_backend.NewReleaseHandler(logger, relFetcher) exitOnError(err, "while creating Helm Release backend storage") case HelmTemplateMode: - handler = helm_storage_backend.NewTemplateHandler(logger, helmCfgFlags) + handler = helm_storage_backend.NewTemplateHandler(logger, relFetcher) default: exitOnError(fmt.Errorf("invalid mode %q", cfg.Mode), "while loading storage backend handler") } diff --git a/deploy/kubernetes/charts/helm-storage-backend/templates/deployment.yaml b/deploy/kubernetes/charts/helm-storage-backend/templates/deployment.yaml index 526944510..8367f7c40 100644 --- a/deploy/kubernetes/charts/helm-storage-backend/templates/deployment.yaml +++ b/deploy/kubernetes/charts/helm-storage-backend/templates/deployment.yaml @@ -47,7 +47,7 @@ spec: path: /healthz port: 8082 resources: - {{- toYaml .Values.resources | nindent 12 }} + {{- toYaml .Values.helmReleaseBackend.resources | nindent 12 }} env: - name: APP_GRPC_ADDR value: ":50051" @@ -76,8 +76,11 @@ spec: httpGet: path: /healthz port: 8083 + volumeMounts: + - mountPath: /tmp + name: cache-volume resources: - {{- toYaml .Values.resources | nindent 12 }} + {{- toYaml .Values.helmTemplateBackend.resources | nindent 12 }} env: - name: APP_GRPC_ADDR value: ":50052" @@ -88,6 +91,9 @@ spec: - name: APP_MODE value: "template" {{- end }} + volumes: + - name: cache-volume + emptyDir: { } {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/deploy/kubernetes/charts/helm-storage-backend/values.yaml b/deploy/kubernetes/charts/helm-storage-backend/values.yaml index f2f832638..2278741bf 100644 --- a/deploy/kubernetes/charts/helm-storage-backend/values.yaml +++ b/deploy/kubernetes/charts/helm-storage-backend/values.yaml @@ -15,12 +15,30 @@ helmReleaseBackend: port: 50051 type: ClusterIP + resources: + limits: + cpu: 100m + memory: 32Mi + requests: + cpu: 30m + memory: 16Mi + + helmTemplateBackend: enabled: true service: port: 50052 type: ClusterIP + resources: + limits: + cpu: 1 + memory: 512Mi + requests: + cpu: 30m + memory: 16Mi + + replicaCount: 1 imagePullSecrets: [] @@ -37,15 +55,6 @@ securityContext: {} # readOnlyRootFilesystem: true # runAsNonRoot: true # runAsUser: 1000 - -resources: - limits: - cpu: 100m - memory: 32Mi - requests: - cpu: 30m - memory: 16Mi - autoscaling: enabled: false minReplicas: 1 diff --git a/internal/helm-storage-backend/errors.go b/internal/helm-storage-backend/errors.go new file mode 100644 index 000000000..6b6006563 --- /dev/null +++ b/internal/helm-storage-backend/errors.go @@ -0,0 +1,13 @@ +package helmstoragebackend + +import ( + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func gRPCInternalError(err error) error { + if err == nil { + return nil + } + return status.Error(codes.Internal, err.Error()) +} diff --git a/internal/helm-storage-backend/helm.go b/internal/helm-storage-backend/helm.go deleted file mode 100644 index a6d7fec9c..000000000 --- a/internal/helm-storage-backend/helm.go +++ /dev/null @@ -1,36 +0,0 @@ -package helmstoragebackend - -import ( - "github.com/pkg/errors" - "helm.sh/helm/v3/pkg/action" - "k8s.io/cli-runtime/pkg/genericclioptions" - - "capact.io/capact/internal/ptr" -) - -const defaultHelmDriver = "secrets" - -type actionConfigurationProducerFn func(flags *genericclioptions.ConfigFlags, driver string, ns string) (*action.Configuration, error) - -// ActionConfigurationProducer returns Configuration with a given input settings. -func ActionConfigurationProducer(flags *genericclioptions.ConfigFlags, driver, ns string) (*action.Configuration, error) { - actionConfig := new(action.Configuration) - helmCfg := &genericclioptions.ConfigFlags{ - APIServer: flags.APIServer, - Insecure: flags.Insecure, - CAFile: flags.CAFile, - BearerToken: flags.BearerToken, - Namespace: ptr.String(ns), - } - - debugLog := func(format string, v ...interface{}) { - // noop - } - - err := actionConfig.Init(helmCfg, ns, driver, debugLog) - if err != nil { - return nil, errors.Wrap(err, "while initializing Helm configuration") - } - - return actionConfig, nil -} diff --git a/internal/helm-storage-backend/helm_rel_fetcher.go b/internal/helm-storage-backend/helm_rel_fetcher.go new file mode 100644 index 000000000..db84dccd7 --- /dev/null +++ b/internal/helm-storage-backend/helm_rel_fetcher.go @@ -0,0 +1,90 @@ +package helmstoragebackend + +import ( + "fmt" + + "github.com/pkg/errors" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/storage/driver" + "k8s.io/cli-runtime/pkg/genericclioptions" + + "capact.io/capact/internal/ptr" +) + +const defaultHelmDriver = "secrets" + +type ( + // HelmRelease holds details about Helm release. + HelmRelease struct { + // Name specifies Helm release name for a given request. + Name string `json:"name"` + // Namespace specifies in which Kubernetes Namespace Helm release is located. + Namespace string `json:"namespace"` + // Driver specifies drivers used for storing the Helm release. + Driver *string `json:"driver,omitempty"` + } + + actionConfigurationProducerFn func(flags *genericclioptions.ConfigFlags, driver string, ns string) (*action.Configuration, error) +) + +// HelmReleaseFetcher provides functionality to fetch Helm release. +type HelmReleaseFetcher struct { + helmCfgFlags *genericclioptions.ConfigFlags + actionConfigurationProducer actionConfigurationProducerFn +} + +// NewHelmReleaseFetcher returns a new HelmReleaseFetcher instance. +func NewHelmReleaseFetcher(flags *genericclioptions.ConfigFlags) *HelmReleaseFetcher { + return &HelmReleaseFetcher{helmCfgFlags: flags, actionConfigurationProducer: actionConfigurationProducer} +} + +// FetchHelmRelease returns a given Helm release. It already handles the gRPC errors properly. +func (f *HelmReleaseFetcher) FetchHelmRelease(tiID string, helmRelease HelmRelease) (*release.Release, error) { + cfg, err := f.actionConfigurationProducer(f.helmCfgFlags, *helmRelease.Driver, helmRelease.Namespace) + if err != nil { + return nil, gRPCInternalError(errors.Wrap(err, "while creating Helm get release client")) + } + + helmGet := action.NewGet(cfg) + + // NOTE: req.resourceVersion is ignored on purpose. + // Based on our contract we always return the latest Helm release revision. + helmGet.Version = latestRevisionIndicator + + rel, err := helmGet.Run(helmRelease.Name) + switch { + case err == nil: + case errors.Is(err, driver.ErrReleaseNotFound): + return nil, status.Error(codes.NotFound, fmt.Sprintf("Helm release '%s/%s' for TypeInstance '%s' was not found", helmRelease.Namespace, helmRelease.Name, tiID)) + default: + return nil, gRPCInternalError(errors.Wrap(err, "while fetching Helm release")) + } + + return rel, nil +} + +// actionConfigurationProducer returns Configuration with a given input settings. +func actionConfigurationProducer(flags *genericclioptions.ConfigFlags, driver, ns string) (*action.Configuration, error) { + actionConfig := new(action.Configuration) + helmCfg := &genericclioptions.ConfigFlags{ + APIServer: flags.APIServer, + Insecure: flags.Insecure, + CAFile: flags.CAFile, + BearerToken: flags.BearerToken, + Namespace: ptr.String(ns), + } + + debugLog := func(format string, v ...interface{}) { + // noop + } + + err := actionConfig.Init(helmCfg, ns, driver, debugLog) + if err != nil { + return nil, errors.Wrap(err, "while initializing Helm configuration") + } + + return actionConfig, nil +} diff --git a/internal/helm-storage-backend/release.go b/internal/helm-storage-backend/release.go index 1b8e6be35..399149bd1 100644 --- a/internal/helm-storage-backend/release.go +++ b/internal/helm-storage-backend/release.go @@ -3,16 +3,10 @@ package helmstoragebackend import ( "context" "encoding/json" - "fmt" "github.com/pkg/errors" "go.uber.org/zap" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/release" - "helm.sh/helm/v3/pkg/storage/driver" - "k8s.io/cli-runtime/pkg/genericclioptions" "capact.io/capact/internal/ptr" pb "capact.io/capact/pkg/hub/api/grpc/storage_backend" @@ -45,14 +39,9 @@ type ( // ReleaseContext holds context used by Helm release storage backend. ReleaseContext struct { - // Name specifies Helm release name for a given request. - Name string `json:"name"` - // Namespace specifies in which Kubernetes Namespace Helm release is located. - Namespace string `json:"namespace"` + HelmRelease // ChartLocation specifies Helm Chart location. ChartLocation string `json:"chartLocation"` - // Driver specifies drivers used for storing the Helm release. - Driver *string `json:"driver,omitempty"` } ) @@ -60,17 +49,15 @@ type ( type ReleaseHandler struct { pb.UnimplementedStorageBackendServer - log *zap.Logger - helmCfgFlags *genericclioptions.ConfigFlags - actionConfigurationProducer actionConfigurationProducerFn + log *zap.Logger + fetcher *HelmReleaseFetcher } // NewReleaseHandler returns new ReleaseHandler. -func NewReleaseHandler(log *zap.Logger, helmCfgFlags *genericclioptions.ConfigFlags) (*ReleaseHandler, error) { +func NewReleaseHandler(log *zap.Logger, helmRelFetcher *HelmReleaseFetcher) (*ReleaseHandler, error) { return &ReleaseHandler{ - log: log, - helmCfgFlags: helmCfgFlags, - actionConfigurationProducer: ActionConfigurationProducer, + log: log, + fetcher: helmRelFetcher, }, nil } @@ -79,7 +66,6 @@ func (h *ReleaseHandler) OnCreate(_ context.Context, req *pb.OnCreateRequest) (* if _, _, err := h.fetchHelmRelease(req.TypeInstanceId, req.Context); err != nil { // check if accessible return nil, err } - return &pb.OnCreateResponse{}, nil } @@ -143,7 +129,7 @@ func (h *ReleaseHandler) getReleaseContext(contextBytes []byte) (*ReleaseContext var ctx ReleaseContext err := json.Unmarshal(contextBytes, &ctx) if err != nil { - return nil, h.internalError(errors.Wrap(err, "while unmarshaling context")) + return nil, errors.Wrap(err, "while unmarshaling context") } if ctx.Driver == nil { @@ -156,39 +142,13 @@ func (h *ReleaseHandler) getReleaseContext(contextBytes []byte) (*ReleaseContext func (h *ReleaseHandler) fetchHelmRelease(ti string, ctx []byte) (*release.Release, *ReleaseContext, error) { relCtx, err := h.getReleaseContext(ctx) if err != nil { - return nil, nil, err + return nil, nil, gRPCInternalError(err) } - helmGet, err := h.newHelmGet(h.helmCfgFlags, *relCtx.Driver, relCtx.Namespace) + rel, err := h.fetcher.FetchHelmRelease(ti, relCtx.HelmRelease) if err != nil { - return nil, nil, h.internalError(errors.Wrap(err, "while creating Helm get release client")) - } - - // NOTE: req.resourceVersion is ignored on purpose. - // Based on our contract we always return the latest Helm release revision. - helmGet.Version = latestRevisionIndicator - - rel, err := helmGet.Run(relCtx.Name) - switch { - case err == nil: - case errors.Is(err, driver.ErrReleaseNotFound): - return nil, nil, status.Error(codes.NotFound, fmt.Sprintf("Helm release '%s/%s' for TypeInstance '%s' was not found", relCtx.Namespace, relCtx.Name, ti)) - default: - return nil, nil, h.internalError(errors.Wrap(err, "while fetching Helm release")) + return nil, nil, err // it already handles grpc errors properly } return rel, relCtx, nil } - -func (h *ReleaseHandler) newHelmGet(flags *genericclioptions.ConfigFlags, driver, ns string) (*action.Get, error) { - actionConfig, err := h.actionConfigurationProducer(flags, driver, ns) - if err != nil { - return nil, err - } - - return action.NewGet(actionConfig), nil -} - -func (h *ReleaseHandler) internalError(err error) error { - return status.Error(codes.Internal, err.Error()) -} diff --git a/internal/helm-storage-backend/release_test.go b/internal/helm-storage-backend/release_test.go index 4fc0e7847..cad7cc30d 100644 --- a/internal/helm-storage-backend/release_test.go +++ b/internal/helm-storage-backend/release_test.go @@ -24,7 +24,7 @@ import ( pb "capact.io/capact/pkg/hub/api/grpc/storage_backend" ) -func TestRelease_GetValue_Success(t *testing.T) { +func TestRelease_CreateGetUpdate_Success(t *testing.T) { tests := []struct { name string @@ -70,10 +70,12 @@ func TestRelease_GetValue_Success(t *testing.T) { TypeInstanceId: test.givenTypeInstanceID, ResourceVersion: test.givenResourceVersion, Context: mustMarshal(t, ReleaseContext{ - Name: releaseName, - Namespace: releaseNamespace, + HelmRelease: HelmRelease{ + Name: releaseName, + Namespace: releaseNamespace, + Driver: test.givenDriver, + }, ChartLocation: chartLocation, - Driver: test.givenDriver, }), } @@ -89,137 +91,42 @@ func TestRelease_GetValue_Success(t *testing.T) { }), } - svc, err := NewReleaseHandler(logger.Noop(), expFlags) - svc.actionConfigurationProducer = mockConfigurationProducer + fetcher := NewHelmReleaseFetcher(expFlags) + fetcher.actionConfigurationProducer = mockConfigurationProducer + svc, err := NewReleaseHandler(logger.Noop(), fetcher) require.NoError(t, err) // when - outVal, gotErr := svc.GetValue(context.Background(), givenReq) + getOut, getErr := svc.GetValue(context.Background(), givenReq) // then - assert.NoError(t, gotErr) - assert.Equal(t, outVal, expResponse) - }) - } -} - -func TestRelease_GetValue_Failures(t *testing.T) { - // globally given - const ( - releaseName = "test-release" - releaseNamespace = "test-namespace" - ) - tests := []struct { - name string - - request *pb.GetValueRequest - internalError error - - expErrMsg string - }{ - { - name: "should return not found error if release name is wrong", - request: &pb.GetValueRequest{ - TypeInstanceId: "123", - Context: mustMarshal(t, ReleaseContext{ - Name: "other-release", - Namespace: releaseNamespace, - ChartLocation: "http://example.com/charts", - }), - }, - expErrMsg: "rpc error: code = NotFound desc = Helm release 'test-namespace/other-release' for TypeInstance '123' was not found", - }, - { - name: "should return not found error if release namespace is wrong", - request: &pb.GetValueRequest{ - TypeInstanceId: "123", - Context: mustMarshal(t, ReleaseContext{ - Name: releaseName, - Namespace: "other-ns", - ChartLocation: "http://example.com/charts", - }), - }, - expErrMsg: "rpc error: code = NotFound desc = Helm release 'other-ns/test-release' for TypeInstance '123' was not found", - }, - { - name: "should return internal error", - request: &pb.GetValueRequest{ - TypeInstanceId: "123", - Context: mustMarshal(t, ReleaseContext{ - Name: releaseName, - Namespace: "other-ns", - ChartLocation: "http://example.com/charts", - }), - }, - internalError: errors.New("internal error"), - expErrMsg: "rpc error: code = Internal desc = while creating Helm get release client: internal error", - }, - } - for _, test := range tests { - test := test - t.Run(test.name, func(t *testing.T) { - t.Parallel() - // given - expHelmRelease := fixHelmRelease(releaseName, releaseNamespace) - expFlags := &genericclioptions.ConfigFlags{ClusterName: ptr.String("testing")} + assert.NoError(t, getErr) + assert.Equal(t, getOut, expResponse) - mockConfigurationProducer := func(inputFlags *genericclioptions.ConfigFlags, inputDriver, inputNs string) (*action.Configuration, error) { - if test.internalError != nil { - return nil, test.internalError - } - producer := mockConfigurationProducer(t, expHelmRelease, expFlags, "secrets") - return producer(inputFlags, inputDriver, inputNs) - } + // when + createOut, createErr := svc.OnCreate(context.Background(), &pb.OnCreateRequest{ + TypeInstanceId: givenReq.TypeInstanceId, + Context: givenReq.Context, + }) - svc, err := NewReleaseHandler(logger.Noop(), expFlags) - svc.actionConfigurationProducer = mockConfigurationProducer - require.NoError(t, err) + // then + assert.NoError(t, createErr) + assert.Empty(t, createOut) // when - outVal, gotErr := svc.GetValue(context.Background(), test.request) + updateOut, updateErr := svc.OnUpdate(context.Background(), &pb.OnUpdateRequest{ + TypeInstanceId: givenReq.TypeInstanceId, + Context: givenReq.Context, + }) // then - assert.EqualError(t, gotErr, test.expErrMsg) - assert.Nil(t, outVal) + assert.NoError(t, updateErr) + assert.Empty(t, updateOut) }) } } -func TestRelease_OnCreate_Success(t *testing.T) { - // given - const ( - releaseName = "test-create-release" - releaseNamespace = "test-create-namespace" - releaseDriver = "configmap" - chartLocation = "http://example.com/charts" - ) - expHelmRelease := fixHelmRelease(releaseName, releaseNamespace) - expFlags := &genericclioptions.ConfigFlags{ClusterName: ptr.String("testing")} - mockConfigurationProducer := mockConfigurationProducer(t, expHelmRelease, expFlags, releaseDriver) - - givenReq := &pb.OnCreateRequest{ - TypeInstanceId: "42", - Context: mustMarshal(t, ReleaseContext{ - Name: releaseName, - Namespace: releaseNamespace, - ChartLocation: chartLocation, - Driver: ptr.String(releaseDriver), - }), - } - - svc, err := NewReleaseHandler(logger.Noop(), expFlags) - svc.actionConfigurationProducer = mockConfigurationProducer - require.NoError(t, err) - - // when - gotOut, gotErr := svc.OnCreate(context.Background(), givenReq) - - // then - assert.NoError(t, gotErr) - assert.Empty(t, gotOut) -} - -func TestRelease_OnCreate_Failures(t *testing.T) { +func TestRelease_CreateGetUpdate_Failures(t *testing.T) { // globally given const ( releaseName = "test-release" @@ -228,18 +135,20 @@ func TestRelease_OnCreate_Failures(t *testing.T) { tests := []struct { name string - request *pb.OnCreateRequest + request *pb.GetValueRequest internalError error expErrMsg string }{ { name: "should return not found error if release name is wrong", - request: &pb.OnCreateRequest{ + request: &pb.GetValueRequest{ TypeInstanceId: "123", Context: mustMarshal(t, ReleaseContext{ - Name: "other-release", - Namespace: releaseNamespace, + HelmRelease: HelmRelease{ + Name: "other-release", + Namespace: releaseNamespace, + }, ChartLocation: "http://example.com/charts", }), }, @@ -247,11 +156,13 @@ func TestRelease_OnCreate_Failures(t *testing.T) { }, { name: "should return not found error if release namespace is wrong", - request: &pb.OnCreateRequest{ + request: &pb.GetValueRequest{ TypeInstanceId: "123", Context: mustMarshal(t, ReleaseContext{ - Name: releaseName, - Namespace: "other-ns", + HelmRelease: HelmRelease{ + Name: releaseName, + Namespace: "other-ns", + }, ChartLocation: "http://example.com/charts", }), }, @@ -259,11 +170,13 @@ func TestRelease_OnCreate_Failures(t *testing.T) { }, { name: "should return internal error", - request: &pb.OnCreateRequest{ + request: &pb.GetValueRequest{ TypeInstanceId: "123", Context: mustMarshal(t, ReleaseContext{ - Name: releaseName, - Namespace: releaseNamespace, + HelmRelease: HelmRelease{ + Name: releaseName, + Namespace: "other-ns", + }, ChartLocation: "http://example.com/charts", }), }, @@ -286,133 +199,38 @@ func TestRelease_OnCreate_Failures(t *testing.T) { producer := mockConfigurationProducer(t, expHelmRelease, expFlags, "secrets") return producer(inputFlags, inputDriver, inputNs) } + fetcher := NewHelmReleaseFetcher(expFlags) + fetcher.actionConfigurationProducer = mockConfigurationProducer - svc, err := NewReleaseHandler(logger.Noop(), expFlags) - svc.actionConfigurationProducer = mockConfigurationProducer + svc, err := NewReleaseHandler(logger.Noop(), fetcher) require.NoError(t, err) // when - outVal, gotErr := svc.OnCreate(context.Background(), test.request) + getOut, getErr := svc.GetValue(context.Background(), test.request) // then - assert.EqualError(t, gotErr, test.expErrMsg) - assert.Nil(t, outVal) - }) - } -} - -func TestRelease_OnUpdate_Success(t *testing.T) { - // given - const ( - releaseName = "test-update-release" - releaseNamespace = "test-update-namespace" - releaseDriver = "configmap" - chartLocation = "http://example.com/charts" - ) - expHelmRelease := fixHelmRelease(releaseName, releaseNamespace) - expFlags := &genericclioptions.ConfigFlags{ClusterName: ptr.String("testing")} - mockConfigurationProducer := mockConfigurationProducer(t, expHelmRelease, expFlags, releaseDriver) - - givenReq := &pb.OnUpdateRequest{ - TypeInstanceId: "42", - Context: mustMarshal(t, ReleaseContext{ - Name: releaseName, - Namespace: releaseNamespace, - ChartLocation: chartLocation, - Driver: ptr.String(releaseDriver), - }), - } - - svc, err := NewReleaseHandler(logger.Noop(), expFlags) - svc.actionConfigurationProducer = mockConfigurationProducer - require.NoError(t, err) + assert.EqualError(t, getErr, test.expErrMsg) + assert.Nil(t, getOut) - // when - gotOut, gotErr := svc.OnUpdate(context.Background(), givenReq) - - // then - assert.NoError(t, gotErr) - assert.Empty(t, gotOut) -} - -func TestRelease_OnUpdate_Failures(t *testing.T) { - // globally given - const ( - releaseName = "test-release" - releaseNamespace = "test-namespace" - ) - tests := []struct { - name string - - request *pb.OnUpdateRequest - internalError error - - expErrMsg string - }{ - { - name: "should return not found error if release name is wrong", - request: &pb.OnUpdateRequest{ - TypeInstanceId: "123", - Context: mustMarshal(t, ReleaseContext{ - Name: "other-release", - Namespace: releaseNamespace, - ChartLocation: "http://example.com/charts", - }), - }, - expErrMsg: "rpc error: code = NotFound desc = Helm release 'test-namespace/other-release' for TypeInstance '123' was not found", - }, - { - name: "should return not found error if release namespace is wrong", - request: &pb.OnUpdateRequest{ - TypeInstanceId: "123", - Context: mustMarshal(t, ReleaseContext{ - Name: releaseName, - Namespace: "other-ns", - ChartLocation: "http://example.com/charts", - }), - }, - expErrMsg: "rpc error: code = NotFound desc = Helm release 'other-ns/test-release' for TypeInstance '123' was not found", - }, - { - name: "should return internal error", - request: &pb.OnUpdateRequest{ - TypeInstanceId: "123", - Context: mustMarshal(t, ReleaseContext{ - Name: releaseName, - Namespace: releaseNamespace, - ChartLocation: "http://example.com/charts", - }), - }, - internalError: errors.New("internal error"), - expErrMsg: "rpc error: code = Internal desc = while creating Helm get release client: internal error", - }, - } - for _, test := range tests { - test := test - t.Run(test.name, func(t *testing.T) { - t.Parallel() - // given - expHelmRelease := fixHelmRelease(releaseName, releaseNamespace) - expFlags := &genericclioptions.ConfigFlags{ClusterName: ptr.String("testing")} - - mockConfigurationProducer := func(inputFlags *genericclioptions.ConfigFlags, inputDriver, inputNs string) (*action.Configuration, error) { - if test.internalError != nil { - return nil, test.internalError - } - producer := mockConfigurationProducer(t, expHelmRelease, expFlags, "secrets") - return producer(inputFlags, inputDriver, inputNs) - } + // when + createOut, createErr := svc.OnCreate(context.Background(), &pb.OnCreateRequest{ + TypeInstanceId: test.request.TypeInstanceId, + Context: test.request.Context, + }) - svc, err := NewReleaseHandler(logger.Noop(), expFlags) - svc.actionConfigurationProducer = mockConfigurationProducer - require.NoError(t, err) + // then + assert.EqualError(t, createErr, test.expErrMsg) + assert.Nil(t, createOut) // when - outVal, gotErr := svc.OnUpdate(context.Background(), test.request) + updateOut, updateErr := svc.OnUpdate(context.Background(), &pb.OnUpdateRequest{ + TypeInstanceId: test.request.TypeInstanceId, + Context: test.request.Context, + }) // then - assert.EqualError(t, gotErr, test.expErrMsg) - assert.Nil(t, outVal) + assert.EqualError(t, updateErr, test.expErrMsg) + assert.Nil(t, updateOut) }) } } @@ -458,8 +276,9 @@ func TestRelease_NOP_Methods(t *testing.T) { producerCalled = true return nil, nil } - svc, err := NewReleaseHandler(logger.Noop(), nil) - svc.actionConfigurationProducer = mockConfigurationProducer + fetcher := NewHelmReleaseFetcher(nil) + fetcher.actionConfigurationProducer = mockConfigurationProducer + svc, err := NewReleaseHandler(logger.Noop(), fetcher) require.NoError(t, err) // when diff --git a/internal/helm-storage-backend/showcase_test.go b/internal/helm-storage-backend/showcase_test.go deleted file mode 100644 index 5dc070ba2..000000000 --- a/internal/helm-storage-backend/showcase_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package helmstoragebackend - -import ( - "context" - "encoding/json" - "fmt" - "os" - "testing" - - "github.com/stretchr/testify/require" - "google.golang.org/grpc" - - pb "capact.io/capact/pkg/hub/api/grpc/storage_backend" -) - -// To run this test, execute: -// GRPC_SECRET_STORAGE_BACKEND_ADDR=":50051" go test ./internal/helm-storage-backend/... -run "^TestShowcase$" -v -count 1 -func TestShowcase(t *testing.T) { - srvAddr := os.Getenv("GRPC_SECRET_STORAGE_BACKEND_ADDR") - if srvAddr == "" { - t.Skip() - } - - conn, err := grpc.Dial(srvAddr, grpc.WithInsecure()) - require.NoError(t, err) - - ctx := context.Background() - client := pb.NewStorageBackendClient(conn) - - // ===== GET ===== - out, err := client.GetValue(ctx, &pb.GetValueRequest{ - TypeInstanceId: "42", - Context: mustMarshal(t, ReleaseContext{ - Name: "example-release", - Namespace: "default", - ChartLocation: "https://charts.bitnami.com/bitnami", - })}) - - require.NoError(t, err) - - details := &ReleaseDetails{} - require.NoError(t, json.Unmarshal(out.Value, details)) - - fmt.Printf("GetValue for valid release") - fmt.Printf("\t\t Name: %s\n", details.Name) - fmt.Printf("\t\t Namespace: %s\n", details.Namespace) - fmt.Printf("\t\t Chart.Name: %s\n", details.Chart.Name) - fmt.Printf("\t\t Chart.Version: %s\n", details.Chart.Version) - fmt.Printf("\t\t Chart.Repo: %s\n", details.Chart.Repo) - - _, err = client.GetValue(ctx, &pb.GetValueRequest{ - TypeInstanceId: "42", - Context: mustMarshal(t, ReleaseContext{ - Name: "fake-release", - Namespace: "default", - ChartLocation: "https://charts.bitnami.com/bitnami", - })}) - - fmt.Printf("GetValue err if release doesn't exist: %v\n", err) - - // ===== UPDATE ===== - _, err = client.OnUpdate(ctx, &pb.OnUpdateRequest{ - Context: mustMarshal(t, ReleaseContext{ - Name: "example-release", - Namespace: "default", - ChartLocation: "https://charts.bitnami.com/bitnami", - })}) - fmt.Printf("OnUpdate err if release exists: %v\n", err) - - _, err = client.OnUpdate(ctx, &pb.OnUpdateRequest{ - TypeInstanceId: "42", - Context: mustMarshal(t, ReleaseContext{ - Name: "fake-release", - Namespace: "default", - ChartLocation: "https://charts.bitnami.com/bitnami", - })}) - fmt.Printf("OnUpdate err if release doesn't exist: %v\n", err) - - // ===== CREATE ===== - _, err = client.OnCreate(ctx, &pb.OnCreateRequest{ - Context: mustMarshal(t, ReleaseContext{ - Name: "example-release", - Namespace: "default", - ChartLocation: "https://charts.bitnami.com/bitnami", - })}) - fmt.Printf("OnCreate err if release exists: %v\n", err) - - _, err = client.OnCreate(ctx, &pb.OnCreateRequest{ - TypeInstanceId: "42", - Context: mustMarshal(t, ReleaseContext{ - Name: "fake-release", - Namespace: "default", - ChartLocation: "https://charts.bitnami.com/bitnami", - })}) - fmt.Printf("OnCreate err if release doesn't exist: %v\n", err) -} diff --git a/internal/helm-storage-backend/template.go b/internal/helm-storage-backend/template.go index 680c32060..8c87b7c3d 100644 --- a/internal/helm-storage-backend/template.go +++ b/internal/helm-storage-backend/template.go @@ -2,32 +2,214 @@ package helmstoragebackend import ( "context" + "encoding/json" - pb "capact.io/capact/pkg/hub/api/grpc/storage_backend" + "github.com/pkg/errors" "go.uber.org/zap" - "k8s.io/cli-runtime/pkg/genericclioptions" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/release" + "sigs.k8s.io/yaml" + + "capact.io/capact/internal/ptr" + pb "capact.io/capact/pkg/hub/api/grpc/storage_backend" + "capact.io/capact/pkg/runner/helm" ) +// repositoryCache Helm cache for repositories +const repositoryCache = "/tmp/helm" + var _ pb.StorageBackendServer = &TemplateHandler{} +type ( + // TemplateContext holds context used by Helm template storage backend. + TemplateContext struct { + // GoTemplate specifies Go template which is used to render returned value. + GoTemplate string `json:"goTemplate"` + // HelmRelease specifies Helm release details against which the render logic should be executed. + HelmRelease HelmRelease `json:"release"` + } +) + // TemplateHandler handles incoming requests to the Helm template storage backend gRPC server. type TemplateHandler struct { pb.UnimplementedStorageBackendServer - log *zap.Logger + log *zap.Logger + fetcher *HelmReleaseFetcher + helmOutputter *helm.Outputter } // NewTemplateHandler returns new TemplateHandler. -func NewTemplateHandler(log *zap.Logger, helmCfgFlags *genericclioptions.ConfigFlags) *TemplateHandler { +func NewTemplateHandler(log *zap.Logger, helmRelFetcher *HelmReleaseFetcher) *TemplateHandler { return &TemplateHandler{ - log: log, + log: log, + fetcher: helmRelFetcher, + helmOutputter: helm.NewOutputter(log, helm.NewRenderer()), } } // GetValue returns a value for a given TypeInstance. -func (h *TemplateHandler) GetValue(_ context.Context, _ *pb.GetValueRequest) (*pb.GetValueResponse, error) { - h.log.Info("Getting value") +func (h *TemplateHandler) GetValue(_ context.Context, req *pb.GetValueRequest) (*pb.GetValueResponse, error) { + h.log.Info("getting entry", zap.String("id", req.TypeInstanceId)) + rel, relCtx, err := h.fetchHelmRelease(req.TypeInstanceId, req.Context) + if err != nil { + return nil, err + } + + value, err := h.renderOutputValue(relCtx.GoTemplate, rel) + if err != nil { + return nil, err + } + + out, err := yaml.YAMLToJSON(value) + if err != nil { + return nil, gRPCInternalError(errors.Wrap(err, "while converting output from YAML to JSON")) + } return &pb.GetValueResponse{ - Value: []byte(`{"handler": "template"}`), + Value: out, }, nil } + +// OnCreate only validates if provided goTemplate can be later rendered on GetValue request. +func (h *TemplateHandler) OnCreate(ctx context.Context, req *pb.OnCreateRequest) (*pb.OnCreateResponse, error) { + h.log.Info("creating entry", zap.String("id", req.TypeInstanceId)) + // dry-run that get will work + _, err := h.GetValue(ctx, &pb.GetValueRequest{ + TypeInstanceId: req.TypeInstanceId, + Context: req.Context, + }) + + if err != nil { + return nil, err + } + return &pb.OnCreateResponse{}, nil +} + +// OnUpdate only validates if provided goTemplate can be later rendered on GetValue request. +func (h *TemplateHandler) OnUpdate(ctx context.Context, req *pb.OnUpdateRequest) (*pb.OnUpdateResponse, error) { + h.log.Info("updating entry", zap.String("id", req.TypeInstanceId)) + // dry-run that get will work + _, err := h.GetValue(ctx, &pb.GetValueRequest{ + TypeInstanceId: req.TypeInstanceId, + Context: req.Context, + }) + + if err != nil { + return nil, err + } + h.log.Info("return entry up") + return &pb.OnUpdateResponse{}, nil +} + +// OnDelete does nothing. +func (*TemplateHandler) OnDelete(context.Context, *pb.OnDeleteRequest) (*pb.OnDeleteResponse, error) { + return &pb.OnDeleteResponse{}, nil +} + +// GetLockedBy does nothing. +func (*TemplateHandler) GetLockedBy(context.Context, *pb.GetLockedByRequest) (*pb.GetLockedByResponse, error) { + return &pb.GetLockedByResponse{}, nil +} + +// OnLock does nothing. +func (*TemplateHandler) OnLock(context.Context, *pb.OnLockRequest) (*pb.OnLockResponse, error) { + return &pb.OnLockResponse{}, nil +} + +// OnUnlock does nothing. +func (*TemplateHandler) OnUnlock(context.Context, *pb.OnUnlockRequest) (*pb.OnUnlockResponse, error) { + return &pb.OnUnlockResponse{}, nil +} + +func (h *TemplateHandler) getReleaseContext(contextBytes []byte) (*TemplateContext, error) { + var ctx TemplateContext + err := json.Unmarshal(contextBytes, &ctx) + if err != nil { + return nil, gRPCInternalError(errors.Wrap(err, "while unmarshaling context")) + } + + if ctx.HelmRelease.Driver == nil { + ctx.HelmRelease.Driver = ptr.String(defaultHelmDriver) + } + + return &ctx, nil +} + +func (h *TemplateHandler) fetchHelmRelease(ti string, ctx []byte) (*release.Release, *TemplateContext, error) { + relCtx, err := h.getReleaseContext(ctx) + if err != nil { + return nil, nil, gRPCInternalError(err) + } + + rel, err := h.fetcher.FetchHelmRelease(ti, relCtx.HelmRelease) + if err != nil { + return nil, nil, err // it already handles grpc errors properly + } + + return rel, relCtx, nil +} + +func (h *TemplateHandler) renderOutputValue(template string, helmRelease *release.Release) ([]byte, error) { + if helmRelease.Chart == nil { + return nil, gRPCInternalError(errors.New("Helm release doesn't have associated Helm chart")) + } + args := helm.OutputArgs{ + GoTemplate: template, + } + + // It is important to load the chart dependencies as by default they are not + // available and if chart was using that we may miss some value and template funcs. + chartDeps, err := h.loadHelmChartDependencies(helmRelease.Chart) + if err != nil { + return nil, gRPCInternalError(errors.Wrap(err, "while ensuring dependency charts")) + } + + helmRelease.Chart.SetDependencies(chartDeps...) + if err := chartutil.ProcessDependencies(helmRelease.Chart, helmRelease.Config); err != nil { + return nil, gRPCInternalError(errors.Wrap(err, "while processing dependency charts")) + } + + data, err := h.helmOutputter.ProduceAdditional(args, helmRelease.Chart, helmRelease) + if err != nil { + return nil, gRPCInternalError(errors.Wrap(err, "while rendering output value")) + } + + return data, nil +} + +func (h *TemplateHandler) loadHelmChartDependencies(chrt *chart.Chart) ([]*chart.Chart, error) { + out := []*chart.Chart{} + if chrt == nil || chrt.Lock == nil || chrt.Lock.Dependencies == nil || len(chrt.Lock.Dependencies) == 0 { + return out, nil + } + + defer h.log.Debug("Loading dependency charts finished") + for _, dep := range chrt.Lock.Dependencies { + cpo := action.ChartPathOptions{ + RepoURL: dep.Repository, + Version: dep.Version, + } + + chartLocation, err := cpo.LocateChart(dep.Name, &cli.EnvSettings{ + RepositoryCache: repositoryCache, + }) + if err != nil { + return nil, errors.Wrapf(err, "while locating %q Helm chart", dep.Name) + } + + h.log.Debug("Loading chart", zap.String("repo", dep.Repository), zap.String("version", dep.Version), zap.String("name", dep.Name)) + + ch, err := loader.Load(chartLocation) + if err != nil { + return nil, errors.Wrapf(err, "while loading %q Helm chart", dep.Name) + } + + out = append(out, ch) + } + + return out, nil +} diff --git a/internal/helm-storage-backend/template_test.go b/internal/helm-storage-backend/template_test.go new file mode 100644 index 000000000..8ab8e6460 --- /dev/null +++ b/internal/helm-storage-backend/template_test.go @@ -0,0 +1,316 @@ +package helmstoragebackend + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/time" + "k8s.io/cli-runtime/pkg/genericclioptions" + + "capact.io/capact/internal/cli/heredoc" + "capact.io/capact/internal/logger" + "capact.io/capact/internal/ptr" + pb "capact.io/capact/pkg/hub/api/grpc/storage_backend" +) + +func TestTemplate_CreateGetAndUpdate_Success(t *testing.T) { + tests := []struct { + name string + + givenDriver *string + givenTypeInstanceID string + givenResourceVersion uint32 + expectedDriver string + }{ + { + name: "should use default driver and return the latest release", + givenTypeInstanceID: "123", + givenDriver: nil, + expectedDriver: "secrets", + }, + { + name: "should use configmap driver and return the latest release", + givenTypeInstanceID: "123", + givenDriver: ptr.String("configmaps"), + expectedDriver: "configmaps", + }, + { + name: "should ignore resourceVersion and return the latest release", + givenTypeInstanceID: "123", + givenResourceVersion: 42, // should be ignored + expectedDriver: "secrets", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + // given + const ( + releaseName = "test-get-release" + releaseNamespace = "test-get-namespace" + ) + + schart, err := loader.Load("./testdata/sample-chart") + require.NoError(t, err) + + expHelmRelease := fixHelmReleaseWithChart(releaseName, releaseNamespace, schart) + expFlags := &genericclioptions.ConfigFlags{ClusterName: ptr.String("testing")} + mockConfigurationProducer := mockConfigurationProducer(t, expHelmRelease, expFlags, test.expectedDriver) + + givenReq := &pb.GetValueRequest{ + TypeInstanceId: test.givenTypeInstanceID, + ResourceVersion: test.givenResourceVersion, + Context: mustMarshal(t, TemplateContext{ + HelmRelease: HelmRelease{ + Name: releaseName, + Namespace: releaseNamespace, + Driver: test.givenDriver, + }, + GoTemplate: heredoc.Doc(` + host: '{{ include "sample-chart.fullname" . }}' + port: '{{ .Values.service.port }}' + superuser: + username: 'psql' + `), + })} + + expResponse := &pb.GetValueResponse{ + Value: mustMarshal(t, map[string]interface{}{ + "host": "test-get-release-sample-chart", + "port": "80", + "superuser": map[string]interface{}{ + "username": "psql", + }, + }), + } + + fetcher := NewHelmReleaseFetcher(expFlags) + fetcher.actionConfigurationProducer = mockConfigurationProducer + svc := NewTemplateHandler(logger.Noop(), fetcher) + + // when + outVal, gotErr := svc.GetValue(context.Background(), givenReq) + + // then + assert.NoError(t, gotErr) + assert.EqualValues(t, outVal, expResponse) + + // when + createOut, createErr := svc.OnCreate(context.Background(), &pb.OnCreateRequest{ + TypeInstanceId: givenReq.TypeInstanceId, + Context: givenReq.Context, + }) + + // then + assert.NoError(t, createErr) + assert.Empty(t, createOut) + + // when + updateOut, updateErr := svc.OnUpdate(context.Background(), &pb.OnUpdateRequest{ + TypeInstanceId: givenReq.TypeInstanceId, + Context: givenReq.Context, + }) + + // then + assert.NoError(t, updateErr) + assert.Empty(t, updateOut) + }) + } +} + +func TestTemplate_CreateGetAndUpdate_Failures(t *testing.T) { + // globally given + const ( + releaseName = "test-release" + releaseNamespace = "test-namespace" + ) + tests := []struct { + name string + + request *pb.GetValueRequest + internalError error + + expErrMsg string + }{ + { + name: "should return not found error if release name is wrong", + request: &pb.GetValueRequest{ + TypeInstanceId: "123", + Context: mustMarshal(t, TemplateContext{ + HelmRelease: HelmRelease{ + Name: "other-release", + Namespace: releaseNamespace, + }, + }), + }, + expErrMsg: "rpc error: code = NotFound desc = Helm release 'test-namespace/other-release' for TypeInstance '123' was not found", + }, + { + name: "should return not found error if release namespace is wrong", + request: &pb.GetValueRequest{ + TypeInstanceId: "123", + Context: mustMarshal(t, TemplateContext{ + HelmRelease: HelmRelease{ + Name: releaseName, + Namespace: "other-ns", + }, + }), + }, + expErrMsg: "rpc error: code = NotFound desc = Helm release 'other-ns/test-release' for TypeInstance '123' was not found", + }, + { + name: "should return error indicating invalid goTemplate", + request: &pb.GetValueRequest{ + TypeInstanceId: "123", + Context: mustMarshal(t, TemplateContext{ + HelmRelease: HelmRelease{ + Name: releaseName, + Namespace: releaseNamespace, + }, + GoTemplate: `host: '{{ .Missing.property }}'`, + }), + }, + expErrMsg: "rpc error: code = Internal desc = while rendering output value: while rendering additional output: while rendering chart: template: test-release-chart/additionalOutputTemplate:1:18: executing \"test-release-chart/additionalOutputTemplate\" at <.Missing.property>: nil pointer evaluating interface {}.property", + }, + { + name: "should return internal error", + request: &pb.GetValueRequest{ + TypeInstanceId: "123", + Context: mustMarshal(t, TemplateContext{ + HelmRelease: HelmRelease{ + Name: releaseName, + Namespace: "other-ns", + }, + }), + }, + internalError: errors.New("internal error"), + expErrMsg: "rpc error: code = Internal desc = while creating Helm get release client: internal error", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + // given + expHelmRelease := fixHelmRelease(releaseName, releaseNamespace) + expFlags := &genericclioptions.ConfigFlags{ClusterName: ptr.String("testing")} + + mockConfigurationProducer := func(inputFlags *genericclioptions.ConfigFlags, inputDriver, inputNs string) (*action.Configuration, error) { + if test.internalError != nil { + return nil, test.internalError + } + producer := mockConfigurationProducer(t, expHelmRelease, expFlags, "secrets") + return producer(inputFlags, inputDriver, inputNs) + } + fetcher := NewHelmReleaseFetcher(expFlags) + fetcher.actionConfigurationProducer = mockConfigurationProducer + + svc := NewTemplateHandler(logger.Noop(), fetcher) + + // when + outVal, gotErr := svc.GetValue(context.Background(), test.request) + + // then + assert.EqualError(t, gotErr, test.expErrMsg) + assert.Nil(t, outVal) + + // when + createOut, createErr := svc.OnCreate(context.Background(), &pb.OnCreateRequest{ + TypeInstanceId: test.request.TypeInstanceId, + Context: test.request.Context, + }) + + // then + assert.EqualError(t, createErr, test.expErrMsg) + assert.Empty(t, createOut) + + // when + updateOut, updateErr := svc.OnUpdate(context.Background(), &pb.OnUpdateRequest{ + TypeInstanceId: test.request.TypeInstanceId, + Context: test.request.Context, + }) + + // then + assert.EqualError(t, updateErr, test.expErrMsg) + assert.Empty(t, updateOut) + }) + } +} + +func TestTemplate_NOP_Methods(t *testing.T) { + // globally given + tests := []struct { + name string + handler func(ctx context.Context, svc *TemplateHandler) (interface{}, error) + }{ + { + name: "no operation for OnDelete", + handler: func(ctx context.Context, svc *TemplateHandler) (interface{}, error) { + return svc.OnDelete(ctx, nil) + }, + }, + { + name: "no operation for GetLockedBy", + handler: func(ctx context.Context, svc *TemplateHandler) (interface{}, error) { + return svc.GetLockedBy(ctx, nil) + }, + }, + { + name: "no operation for OnLock", + handler: func(ctx context.Context, svc *TemplateHandler) (interface{}, error) { + return svc.OnLock(ctx, nil) + }, + }, + { + name: "no operation for OnUnlock", + handler: func(ctx context.Context, svc *TemplateHandler) (interface{}, error) { + return svc.OnUnlock(ctx, nil) + }, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + // given + producerCalled := false + mockConfigurationProducer := func(_ *genericclioptions.ConfigFlags, _, _ string) (*action.Configuration, error) { + producerCalled = true + return nil, nil + } + fetcher := NewHelmReleaseFetcher(nil) + fetcher.actionConfigurationProducer = mockConfigurationProducer + svc := NewTemplateHandler(logger.Noop(), fetcher) + + // when + outVal, gotErr := test.handler(context.Background(), svc) + + // then + assert.NoError(t, gotErr) + assert.False(t, producerCalled) + assert.Empty(t, outVal) + }) + } +} + +func fixHelmReleaseWithChart(name, ns string, chrt *chart.Chart) *release.Release { + now := time.Now() + return &release.Release{ + Name: name, + Namespace: ns, + Info: &release.Info{ + FirstDeployed: now, + LastDeployed: now, + Description: "Named Release Stub", + }, + Chart: chrt, + } +} diff --git a/internal/helm-storage-backend/testdata/sample-chart/Chart.yaml b/internal/helm-storage-backend/testdata/sample-chart/Chart.yaml new file mode 100644 index 000000000..8e0e3dcf5 --- /dev/null +++ b/internal/helm-storage-backend/testdata/sample-chart/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: sample-chart +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/internal/helm-storage-backend/testdata/sample-chart/templates/_helpers.tpl b/internal/helm-storage-backend/testdata/sample-chart/templates/_helpers.tpl new file mode 100644 index 000000000..70962474f --- /dev/null +++ b/internal/helm-storage-backend/testdata/sample-chart/templates/_helpers.tpl @@ -0,0 +1,51 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "sample-chart.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "sample-chart.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "sample-chart.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "sample-chart.labels" -}} +helm.sh/chart: {{ include "sample-chart.chart" . }} +{{ include "sample-chart.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "sample-chart.selectorLabels" -}} +app.kubernetes.io/name: {{ include "sample-chart.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/internal/helm-storage-backend/testdata/sample-chart/templates/service.yaml b/internal/helm-storage-backend/testdata/sample-chart/templates/service.yaml new file mode 100644 index 000000000..fbb735d5b --- /dev/null +++ b/internal/helm-storage-backend/testdata/sample-chart/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "sample-chart.fullname" . }} + labels: + {{- include "sample-chart.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "sample-chart.selectorLabels" . | nindent 4 }} diff --git a/internal/helm-storage-backend/testdata/sample-chart/values.yaml b/internal/helm-storage-backend/testdata/sample-chart/values.yaml new file mode 100644 index 000000000..c342d7209 --- /dev/null +++ b/internal/helm-storage-backend/testdata/sample-chart/values.yaml @@ -0,0 +1,3 @@ +service: + type: ClusterIP + port: 80 diff --git a/pkg/hub/client/local/fields.go b/pkg/hub/client/local/fields.go index 9d4104ee9..3f95c393f 100644 --- a/pkg/hub/client/local/fields.go +++ b/pkg/hub/client/local/fields.go @@ -5,15 +5,16 @@ import ( ) var typeInstancesFieldsRegistry = map[TypeInstancesQueryFields]string{ - TypeInstanceRootFields: rootFields, - TypeInstanceTypeRefFields: typeRefFields, - TypeInstanceBackendFields: backendFields, - TypeInstanceUsesIDField: usesIDField, - TypeInstanceUsedByIDField: usedByIDField, - TypeInstanceLatestResourceVersionField: latestResourceVersionField, - TypeInstanceAllFields: typeInstanceAllFields, - TypeInstanceUsesAllFields: typeInstanceUsesAllFields, - TypeInstanceUsedByAllFields: typeInstanceUsedByAllFields, + TypeInstanceRootFields: rootFields, + TypeInstanceTypeRefFields: typeRefFields, + TypeInstanceBackendFields: backendFields, + TypeInstanceUsesIDField: usesIDField, + TypeInstanceUsedByIDField: usedByIDField, + TypeInstanceLatestResourceVersionVersionField: latestResourceVersionField, + TypeInstanceLatestResourceVersionFields: latestResourceVersionFields, + TypeInstanceAllFields: typeInstanceAllFields, + TypeInstanceUsesAllFields: typeInstanceUsesAllFields, + TypeInstanceUsedByAllFields: typeInstanceUsedByAllFields, // grow the extracted fields if needed } @@ -49,6 +50,11 @@ var ( resourceVersion }` + latestResourceVersionFields = fmt.Sprintf(` + latestResourceVersion { + %s + }`, typeInstanceResourceVersion) + typeInstanceUsesAllFields = fmt.Sprintf(` uses { %s diff --git a/pkg/hub/client/local/options.go b/pkg/hub/client/local/options.go index 358520473..7ed06a7b4 100644 --- a/pkg/hub/client/local/options.go +++ b/pkg/hub/client/local/options.go @@ -18,8 +18,10 @@ const ( TypeInstanceUsedByIDField // TypeInstanceUsesIDField returns IDs for Uses field. TypeInstanceUsesIDField - // TypeInstanceLatestResourceVersionField returns resourceVersion for LatestResourceVersion field. - TypeInstanceLatestResourceVersionField + // TypeInstanceLatestResourceVersionVersionField returns resourceVersion for LatestResourceVersion field. + TypeInstanceLatestResourceVersionVersionField + // TypeInstanceLatestResourceVersionFields returns TypeInstance's LatestResourceVersion fields. + TypeInstanceLatestResourceVersionFields // TypeInstanceAllFields returns all TypeInstance fields. TypeInstanceAllFields // TypeInstanceUsesAllFields returns TypeInstance's Uses field.