Skip to content

Commit

Permalink
Example of unit-testing with dynamic client. (#2857)
Browse files Browse the repository at this point in the history
* Example of unit-testing with dynamic client.

* Add tests for various status responses

* DRY up constant values.

* Document clientGetter field.
  • Loading branch information
absoludity committed May 25, 2021
1 parent d77d082 commit b453946
Show file tree
Hide file tree
Showing 2 changed files with 248 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -32,46 +32,88 @@ import (

corev1 "github.com/kubeapps/kubeapps/cmd/kubeapps-apis/gen/core/packages/v1alpha1"
"github.com/kubeapps/kubeapps/cmd/kubeapps-apis/gen/plugins/kapp_controller/packages/v1alpha1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"k8s.io/client-go/rest"
)

const (
// See https://carvel.dev/kapp-controller/docs/latest/packaging/#package-cr
packageGroup = "package.carvel.dev"
packageVersion = "v1alpha1"
packagesResource = "packages"

// See https://carvel.dev/kapp-controller/docs/latest/packaging/#packagerepository-cr
installPackageGroup = "install.package.carvel.dev"
installPackageVersion = "v1alpha1"
repositoriesResource = "packagerepositories"
)

// Server implements the kapp-controller packages v1alpha1 interface.
type Server struct {
v1alpha1.UnimplementedPackagesServiceServer

// clientGetter is a field so that it can be switched in tests for
// a fake client. NewServer() below sets this automatically with the
// non-test implementation.
clientGetter func(context.Context) (dynamic.Interface, error)
}

// GetAvailablePackages streams the available packages based on the request.
func (s *Server) GetAvailablePackages(ctx context.Context, request *corev1.GetAvailablePackagesRequest) (*corev1.GetAvailablePackagesResponse, error) {
// clientForRequestContext returns a k8s client for use during interactions with the cluster.
// This will be updated to use the user credential from the request context but for now
// simply returns th in-cluster config (which is linked to a service-account with demo RBAC).
func clientForRequestContext(ctx context.Context) (dynamic.Interface, error) {
// TODO: replace incluster config with the user config using token from request meta.
config, err := rest.InClusterConfig()
if err != nil {
return nil, fmt.Errorf("unable to create incluster config: %w", err)
return nil, fmt.Errorf("unable to get client config: %w", err)
}

client, err := dynamic.NewForConfig(config)
if err != nil {
return nil, fmt.Errorf("unable to create dynamic client: %w", err)
}

packageResource := schema.GroupVersionResource{Group: "package.carvel.dev", Version: "v1alpha1", Resource: "packages"}
return client, nil
}

// NewServer returns a Server automatically configured with a function to obtain
// the k8s client config.
func NewServer() *Server {
return &Server{
clientGetter: clientForRequestContext,
}
}

// GetAvailablePackages streams the available packages based on the request.
func (s *Server) GetAvailablePackages(ctx context.Context, request *corev1.GetAvailablePackagesRequest) (*corev1.GetAvailablePackagesResponse, error) {
if s.clientGetter == nil {
return nil, status.Errorf(codes.Internal, "server not configured with configGetter")
}
client, err := s.clientGetter(ctx)
if err != nil {
return nil, status.Errorf(codes.FailedPrecondition, fmt.Sprintf("unable to get client : %v", err))
}

packageResource := schema.GroupVersionResource{Group: packageGroup, Version: packageVersion, Resource: packagesResource}

pkgs, err := client.Resource(packageResource).List(ctx, metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("unable to list kapp-controller packages: %w", err)
return nil, status.Errorf(codes.Internal, fmt.Sprintf("unable to list kapp-controller packages: %v", err))
}

responsePackages := []*corev1.AvailablePackage{}
for _, pkgUnstructured := range pkgs.Items {
pkg := &corev1.AvailablePackage{}
name, found, err := unstructured.NestedString(pkgUnstructured.Object, "spec", "publicName")
if err != nil || !found {
return nil, fmt.Errorf("required field publicName not found on kapp-controller package: %w:\n%v", err, pkgUnstructured.Object)
return nil, status.Errorf(codes.Internal, "required field publicName not found on kapp-controller package: %v:\n%v", err, pkgUnstructured.Object)
}
pkg.Name = name

version, found, err := unstructured.NestedString(pkgUnstructured.Object, "spec", "version")
if err != nil || !found {
return nil, fmt.Errorf("required field version not found on kapp-controller package: %w:\n%v", err, pkgUnstructured.Object)
return nil, status.Errorf(codes.Internal, "required field version not found on kapp-controller package: %v:\n%v", err, pkgUnstructured.Object)
}
pkg.Version = version
responsePackages = append(responsePackages, pkg)
Expand All @@ -94,7 +136,7 @@ func (s *Server) GetPackageRepositories(ctx context.Context, request *corev1.Get
return nil, fmt.Errorf("unable to create dynamic client: %w", err)
}

repositoryResource := schema.GroupVersionResource{Group: "install.package.carvel.dev", Version: "v1alpha1", Resource: "packagerepositories"}
repositoryResource := schema.GroupVersionResource{Group: installPackageGroup, Version: installPackageVersion, Resource: repositoriesResource}

// Currently checks globally. Update to handle namespaced requests (?)
repos, err := client.Resource(repositoryResource).List(ctx, metav1.ListOptions{})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/*
Copyright © 2021 VMware
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package main

import (
"context"
"fmt"
"testing"

"github.com/google/go-cmp/cmp"
corev1 "github.com/kubeapps/kubeapps/cmd/kubeapps-apis/gen/core/packages/v1alpha1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/dynamic/fake"
)

func TestGetAvailablePackagesStatus(t *testing.T) {
testCases := []struct {
name string
clientGetter func(context.Context) (dynamic.Interface, error)
statusCode codes.Code
}{
{
name: "returns internal error status when no getter configured",
clientGetter: nil,
statusCode: codes.Internal,
},
{
name: "returns failed-precondition when configGetter itself errors",
clientGetter: func(context.Context) (dynamic.Interface, error) {
return nil, fmt.Errorf("Bang!")
},
statusCode: codes.FailedPrecondition,
},
{
name: "returns an internal error status if response does not contain publicName",
clientGetter: func(context.Context) (dynamic.Interface, error) {
return fake.NewSimpleDynamicClientWithCustomListKinds(
runtime.NewScheme(),
map[schema.GroupVersionResource]string{
{Group: packageGroup, Version: packageVersion, Resource: packagesResource}: "PackageList",
},
packageFromSpec(map[string]interface{}{
"version": "1.2.3",
}),
), nil
},
statusCode: codes.Internal,
},
{
name: "returns an internal error status if response does not contain version",
clientGetter: func(context.Context) (dynamic.Interface, error) {
return fake.NewSimpleDynamicClientWithCustomListKinds(
runtime.NewScheme(),
map[schema.GroupVersionResource]string{
{Group: packageGroup, Version: packageVersion, Resource: packagesResource}: "PackageList",
},
packageFromSpec(map[string]interface{}{
"publicName": "someName",
}),
), nil
},
statusCode: codes.Internal,
},
{
name: "returns without error if items contain required fields",
clientGetter: func(context.Context) (dynamic.Interface, error) {
return fake.NewSimpleDynamicClientWithCustomListKinds(
runtime.NewScheme(),
map[schema.GroupVersionResource]string{
{Group: packageGroup, Version: packageVersion, Resource: packagesResource}: "PackageList",
},
packageFromSpec(map[string]interface{}{
"publicName": "someName",
"version": "1.2.3",
}),
), nil
},
statusCode: codes.OK,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := Server{clientGetter: tc.clientGetter}

_, err := s.GetAvailablePackages(context.Background(), &corev1.GetAvailablePackagesRequest{})

if err == nil && tc.statusCode != codes.OK {
t.Fatalf("got: nil, want: error")
}

if got, want := status.Code(err), tc.statusCode; got != want {
t.Errorf("got: %+v, want: %+v", got, want)
}
})
}

}

func packageFromSpec(spec map[string]interface{}) *unstructured.Unstructured {
return &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": fmt.Sprintf("%s/%s", packageGroup, packageVersion),
"kind": "Package",
"metadata": map[string]interface{}{
"name": fmt.Sprintf("%s.%s", spec["publicName"], spec["version"]),
},
"spec": spec,
},
}
}

func packagesFromSpecs(specs []map[string]interface{}) []runtime.Object {
pkgs := []runtime.Object{}
for _, s := range specs {
pkgs = append(pkgs, packageFromSpec(s))
}
return pkgs
}

func TestGetAvailablePackages(t *testing.T) {
testCases := []struct {
name string
packageSpecs []map[string]interface{}
expectedPackages []*corev1.AvailablePackage
}{
{
name: "it returns carvel packages from the cluster",
packageSpecs: []map[string]interface{}{
{
"publicName": "tetris.foo.example.com",
"version": "1.2.3",
},
{
"publicName": "another.foo.example.com",
"version": "1.2.5",
},
},
expectedPackages: []*corev1.AvailablePackage{
{
Name: "another.foo.example.com",
Version: "1.2.5",
},
{
Name: "tetris.foo.example.com",
Version: "1.2.3",
},
},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
pkgs := packagesFromSpecs(tc.packageSpecs)
s := Server{
clientGetter: func(context.Context) (dynamic.Interface, error) {
return fake.NewSimpleDynamicClientWithCustomListKinds(
runtime.NewScheme(),
map[schema.GroupVersionResource]string{
{Group: packageGroup, Version: packageVersion, Resource: packagesResource}: "PackageList",
},
pkgs...,
), nil
},
}

response, err := s.GetAvailablePackages(context.Background(), &corev1.GetAvailablePackagesRequest{})
if err != nil {
t.Fatalf("%+v", err)
}

if got, want := response.Packages, tc.expectedPackages; !cmp.Equal(got, want, cmp.Comparer(pkgEqual)) {
t.Errorf("mismatch (-want +got):\n%s", cmp.Diff(want, got, cmp.Comparer(pkgEqual)))
}
})
}

}

func pkgEqual(a, b *corev1.AvailablePackage) bool {
return a.Name == b.Name && a.Version == b.Version
}

0 comments on commit b453946

Please sign in to comment.