diff --git a/README.md b/README.md index de5c9066..29bdf62b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ The Devfile Parser library is a Golang module that: 3. generates Kubernetes objects for the various devfile resources. 4. defines util functions for the devfile. +The function documentation can be accessed via [pkg.go.dev](https://pkg.go.dev/github.com/devfile/library). +1. To parse a devfile, visit pkg/devfile/parse.go +2. To get the Kubernetes structs from the devfile, visit pkg/devfile/generator/generators.go ## Usage diff --git a/go.mod b/go.mod index 86679275..5c621420 100644 --- a/go.mod +++ b/go.mod @@ -11,8 +11,10 @@ require ( github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 github.com/mattn/go-colorable v0.1.2 // indirect github.com/mattn/go-isatty v0.0.12 // indirect + github.com/openshift/api v3.9.0+incompatible github.com/pkg/errors v0.8.1 github.com/spf13/afero v1.2.2 + github.com/stretchr/testify v1.6.1 // indirect github.com/xeipuuv/gojsonschema v1.2.0 golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e // indirect golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f // indirect diff --git a/go.sum b/go.sum index 7580fec1..0f4ae009 100644 --- a/go.sum +++ b/go.sum @@ -240,6 +240,8 @@ github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGV github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.8.1 h1:C5Dqfs/LeauYDX0jJXIe2SWmwCbGzx9yF8C8xy3Lh34= github.com/onsi/gomega v1.8.1/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= +github.com/openshift/api v3.9.0+incompatible h1:fJ/KsefYuZAjmrr3+5U9yZIZbTOpVkDDLDLFresAeYs= +github.com/openshift/api v3.9.0+incompatible/go.mod h1:dh9o4Fs58gpFXGSYfnVxGR9PnV53I8TW84pQaJDdGiY= github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= @@ -288,6 +290,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= @@ -449,6 +453,8 @@ gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -464,6 +470,7 @@ k8s.io/apimachinery v0.18.2/go.mod h1:9SnR/e11v5IbyPCGbvJViimtJ0SwHG4nfZFjU77ftc k8s.io/apiserver v0.17.2/go.mod h1:lBmw/TtQdtxvrTk0e2cgtOxHizXI+d0mmGQURIHQZlo= k8s.io/apiserver v0.18.2/go.mod h1:Xbh066NqrZO8cbsoenCwyDJ1OSi8Ag8I2lezeHxzwzw= k8s.io/client-go v0.17.2/go.mod h1:QAzRgsa0C2xl4/eVpeVAZMvikCn8Nm81yqVx3Kk9XYI= +k8s.io/client-go v0.18.2 h1:aLB0iaD4nmwh7arT2wIn+lMnAq7OswjaejkQ8p9bBYE= k8s.io/client-go v0.18.2/go.mod h1:Xcm5wVGXX9HAA2JJ2sSBUn3tCJ+4SVlCbl2MNNv+CIU= k8s.io/code-generator v0.17.2/go.mod h1:DVmfPQgxQENqDIzVR2ddLXMH34qeszkKSdH/N+s+38s= k8s.io/code-generator v0.18.2/go.mod h1:+UHX5rSbxmR8kzS+FAv7um6dtYrZokQvjHpDSYRVkTc= diff --git a/pkg/devfile/generator/generators.go b/pkg/devfile/generator/generators.go new file mode 100644 index 00000000..ac101270 --- /dev/null +++ b/pkg/devfile/generator/generators.go @@ -0,0 +1,277 @@ +package generator + +import ( + buildv1 "github.com/openshift/api/build/v1" + imagev1 "github.com/openshift/api/image/v1" + routev1 "github.com/openshift/api/route/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + extensionsv1 "k8s.io/api/extensions/v1beta1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/devfile/library/pkg/devfile/parser" +) + +const ( + // DevfileSourceVolumeMount is the default directory to mount the volume in the container + DevfileSourceVolumeMount = "/projects" + + // EnvProjectsRoot is the env defined for project mount in a component container when component's mountSources=true + EnvProjectsRoot = "PROJECTS_ROOT" + + // EnvProjectsSrc is the env defined for path to the project source in a component container + EnvProjectsSrc = "PROJECT_SOURCE" + + deploymentKind = "Deployment" + deploymentAPIVersion = "apps/v1" +) + +// GetTypeMeta gets a type meta of the specified kind and version +func GetTypeMeta(kind string, APIVersion string) metav1.TypeMeta { + return metav1.TypeMeta{ + Kind: kind, + APIVersion: APIVersion, + } +} + +// GetObjectMeta gets an object meta with the parameters +func GetObjectMeta(name, namespace string, labels, annotations map[string]string) metav1.ObjectMeta { + + objectMeta := metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: labels, + Annotations: annotations, + } + + return objectMeta +} + +// GetContainers iterates through the devfile components and returns a slice of the corresponding containers +func GetContainers(devfileObj parser.DevfileObj) ([]corev1.Container, error) { + var containers []corev1.Container + for _, comp := range devfileObj.Data.GetDevfileContainerComponents() { + envVars := convertEnvs(comp.Container.Env) + resourceReqs := getResourceReqs(comp) + ports := convertPorts(comp.Container.Endpoints) + containerParams := containerParams{ + Name: comp.Name, + Image: comp.Container.Image, + IsPrivileged: false, + Command: comp.Container.Command, + Args: comp.Container.Args, + EnvVars: envVars, + ResourceReqs: resourceReqs, + Ports: ports, + } + container := getContainer(containerParams) + + // If `mountSources: true` was set PROJECTS_ROOT & PROJECT_SOURCE env + if comp.Container.MountSources == nil || *comp.Container.MountSources { + syncRootFolder := addSyncRootFolder(container, comp.Container.SourceMapping) + + err := addSyncFolder(container, syncRootFolder, devfileObj.Data.GetProjects()) + if err != nil { + return nil, err + } + } + containers = append(containers, *container) + } + return containers, nil +} + +// DeploymentParams is a struct that contains the required data to create a deployment object +type DeploymentParams struct { + TypeMeta metav1.TypeMeta + ObjectMeta metav1.ObjectMeta + InitContainers []corev1.Container + Containers []corev1.Container + Volumes []corev1.Volume + PodSelectorLabels map[string]string +} + +// GetDeployment gets a deployment object +func GetDeployment(deployParams DeploymentParams) *appsv1.Deployment { + + podTemplateSpecParams := podTemplateSpecParams{ + ObjectMeta: deployParams.ObjectMeta, + InitContainers: deployParams.InitContainers, + Containers: deployParams.Containers, + Volumes: deployParams.Volumes, + } + + deploySpecParams := deploymentSpecParams{ + PodTemplateSpec: *getPodTemplateSpec(podTemplateSpecParams), + PodSelectorLabels: deployParams.PodSelectorLabels, + } + + deployment := &appsv1.Deployment{ + TypeMeta: deployParams.TypeMeta, + ObjectMeta: deployParams.ObjectMeta, + Spec: *getDeploymentSpec(deploySpecParams), + } + + return deployment +} + +// PVCParams is a struct to create PVC +type PVCParams struct { + TypeMeta metav1.TypeMeta + ObjectMeta metav1.ObjectMeta + Quantity resource.Quantity +} + +// GetPVC returns a PVC +func GetPVC(pvcParams PVCParams) *corev1.PersistentVolumeClaim { + pvcSpec := getPVCSpec(pvcParams.Quantity) + + pvc := &corev1.PersistentVolumeClaim{ + TypeMeta: pvcParams.TypeMeta, + ObjectMeta: pvcParams.ObjectMeta, + Spec: *pvcSpec, + } + + return pvc +} + +// ServiceParams is a struct that contains the required data to create a service object +type ServiceParams struct { + TypeMeta metav1.TypeMeta + ObjectMeta metav1.ObjectMeta + SelectorLabels map[string]string +} + +// GetService gets the service +func GetService(devfileObj parser.DevfileObj, serviceParams ServiceParams) (*corev1.Service, error) { + + serviceSpec, err := getServiceSpec(devfileObj, serviceParams.SelectorLabels) + if err != nil { + return nil, err + } + + service := &corev1.Service{ + TypeMeta: serviceParams.TypeMeta, + ObjectMeta: serviceParams.ObjectMeta, + Spec: *serviceSpec, + } + + return service, nil +} + +// IngressParams is a struct that contains the required data to create an ingress object +type IngressParams struct { + TypeMeta metav1.TypeMeta + ObjectMeta metav1.ObjectMeta + IngressSpecParams IngressSpecParams +} + +// GetIngress gets an ingress +func GetIngress(ingressParams IngressParams) *extensionsv1.Ingress { + + ingressSpec := getIngressSpec(ingressParams.IngressSpecParams) + + ingress := &extensionsv1.Ingress{ + TypeMeta: ingressParams.TypeMeta, + ObjectMeta: ingressParams.ObjectMeta, + Spec: *ingressSpec, + } + + return ingress +} + +// RouteParams is a struct that contains the required data to create a route object +type RouteParams struct { + TypeMeta metav1.TypeMeta + ObjectMeta metav1.ObjectMeta + RouteSpecParams RouteSpecParams +} + +// GetRoute gets a route +func GetRoute(routeParams RouteParams) *routev1.Route { + + routeSpec := getRouteSpec(routeParams.RouteSpecParams) + + route := &routev1.Route{ + TypeMeta: routeParams.TypeMeta, + ObjectMeta: routeParams.ObjectMeta, + Spec: *routeSpec, + } + + return route +} + +// GetOwnerReference generates an ownerReference from the deployment which can then be set as +// owner for various Kubernetes objects and ensure that when the owner object is deleted from the +// cluster, all other objects are automatically removed by Kubernetes garbage collector +func GetOwnerReference(deployment *appsv1.Deployment) metav1.OwnerReference { + + ownerReference := metav1.OwnerReference{ + APIVersion: deploymentAPIVersion, + Kind: deploymentKind, + Name: deployment.Name, + UID: deployment.UID, + } + + return ownerReference +} + +// BuildConfigParams is a struct that contains the required data to create a build config object +type BuildConfigParams struct { + TypeMeta metav1.TypeMeta + ObjectMeta metav1.ObjectMeta + BuildConfigSpecParams BuildConfigSpecParams +} + +// GetBuildConfig gets a build config +func GetBuildConfig(buildConfigParams BuildConfigParams) *buildv1.BuildConfig { + + buildConfigSpec := getBuildConfigSpec(buildConfigParams.BuildConfigSpecParams) + + buildConfig := &buildv1.BuildConfig{ + TypeMeta: buildConfigParams.TypeMeta, + ObjectMeta: buildConfigParams.ObjectMeta, + Spec: *buildConfigSpec, + } + + return buildConfig +} + +// GetSourceBuildStrategy gets the source build strategy +func GetSourceBuildStrategy(imageName, imageNamespace string) buildv1.BuildStrategy { + return buildv1.BuildStrategy{ + SourceStrategy: &buildv1.SourceBuildStrategy{ + From: corev1.ObjectReference{ + Kind: "ImageStreamTag", + Name: imageName, + Namespace: imageNamespace, + }, + }, + } +} + +// GetDockerBuildStrategy gets the docker build strategy +func GetDockerBuildStrategy(dockerfilePath string, env []corev1.EnvVar) buildv1.BuildStrategy { + return buildv1.BuildStrategy{ + Type: buildv1.DockerBuildStrategyType, + DockerStrategy: &buildv1.DockerBuildStrategy{ + DockerfilePath: dockerfilePath, + Env: env, + }, + } +} + +// ImageStreamParams is a struct that contains the required data to create an image stream object +type ImageStreamParams struct { + TypeMeta metav1.TypeMeta + ObjectMeta metav1.ObjectMeta +} + +// GetImageStream is a function to return the image stream +func GetImageStream(imageStreamParams ImageStreamParams) imagev1.ImageStream { + imageStream := imagev1.ImageStream{ + TypeMeta: imageStreamParams.TypeMeta, + ObjectMeta: imageStreamParams.ObjectMeta, + } + return imageStream +} diff --git a/pkg/devfile/generator/generators_test.go b/pkg/devfile/generator/generators_test.go new file mode 100644 index 00000000..478c1ca0 --- /dev/null +++ b/pkg/devfile/generator/generators_test.go @@ -0,0 +1,163 @@ +package generator + +import ( + "reflect" + "testing" + + v1 "github.com/devfile/api/pkg/apis/workspaces/v1alpha2" + "github.com/devfile/library/pkg/devfile/parser" + "github.com/devfile/library/pkg/testingutil" + + corev1 "k8s.io/api/core/v1" +) + +var fakeResources corev1.ResourceRequirements + +func init() { + fakeResources, _ = testingutil.FakeResourceRequirements("0.5m", "300Mi") +} + +func TestGetContainers(t *testing.T) { + + containerNames := []string{"testcontainer1", "testcontainer2"} + containerImages := []string{"image1", "image2"} + trueMountSources := true + falseMountSources := false + tests := []struct { + name string + containerComponents []v1.Component + wantContainerName string + wantContainerImage string + wantContainerEnv []corev1.EnvVar + wantContainerVolMount []corev1.VolumeMount + wantErr bool + }{ + { + name: "Case 1: Container with default project root", + containerComponents: []v1.Component{ + { + Name: containerNames[0], + ComponentUnion: v1.ComponentUnion{ + Container: &v1.ContainerComponent{ + Container: v1.Container{ + Image: containerImages[0], + MountSources: &trueMountSources, + }, + }, + }, + }, + }, + wantContainerName: containerNames[0], + wantContainerImage: containerImages[0], + wantContainerEnv: []corev1.EnvVar{ + + { + Name: "PROJECTS_ROOT", + Value: "/projects", + }, + { + Name: "PROJECT_SOURCE", + Value: "/projects/test-project", + }, + }, + wantContainerVolMount: []corev1.VolumeMount{ + { + Name: "devfile-projects", + MountPath: "/projects", + }, + }, + }, + { + name: "Case 2: Container with source mapping", + containerComponents: []v1.Component{ + { + Name: containerNames[0], + ComponentUnion: v1.ComponentUnion{ + Container: &v1.ContainerComponent{ + Container: v1.Container{ + Image: containerImages[0], + MountSources: &trueMountSources, + SourceMapping: "/myroot", + }, + }, + }, + }, + }, + wantContainerName: containerNames[0], + wantContainerImage: containerImages[0], + wantContainerEnv: []corev1.EnvVar{ + + { + Name: "PROJECTS_ROOT", + Value: "/myroot", + }, + { + Name: "PROJECT_SOURCE", + Value: "/myroot/test-project", + }, + }, + wantContainerVolMount: []corev1.VolumeMount{ + { + Name: "devfile-projects", + MountPath: "/myroot", + }, + }, + }, + { + name: "Case 3: Container with no mount source", + containerComponents: []v1.Component{ + { + Name: containerNames[0], + ComponentUnion: v1.ComponentUnion{ + Container: &v1.ContainerComponent{ + Container: v1.Container{ + Image: containerImages[0], + MountSources: &falseMountSources, + }, + }, + }, + }, + }, + wantContainerName: containerNames[0], + wantContainerImage: containerImages[0], + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + devObj := parser.DevfileObj{ + Data: &testingutil.TestDevfileData{ + Components: tt.containerComponents, + }, + } + + containers, err := GetContainers(devObj) + // Unexpected error + if (err != nil) != tt.wantErr { + t.Errorf("TestGetContainers() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // Expected error and got an err + if tt.wantErr && err != nil { + return + } + + for _, container := range containers { + if container.Name != tt.wantContainerName { + t.Errorf("TestGetContainers error: Name mismatch - got: %s, wanted: %s", container.Name, tt.wantContainerName) + } + if container.Image != tt.wantContainerImage { + t.Errorf("TestGetContainers error: Image mismatch - got: %s, wanted: %s", container.Image, tt.wantContainerImage) + } + if len(container.Env) > 0 && !reflect.DeepEqual(container.Env, tt.wantContainerEnv) { + t.Errorf("TestGetContainers error: Env mismatch - got: %+v, wanted: %+v", container.Env, tt.wantContainerEnv) + } + if len(container.VolumeMounts) > 0 && !reflect.DeepEqual(container.VolumeMounts, tt.wantContainerVolMount) { + t.Errorf("TestGetContainers error: Vol Mount mismatch - got: %+v, wanted: %+v", container.VolumeMounts, tt.wantContainerVolMount) + } + } + }) + } + +} diff --git a/pkg/devfile/generator/utils.go b/pkg/devfile/generator/utils.go new file mode 100644 index 00000000..e0a4dd7c --- /dev/null +++ b/pkg/devfile/generator/utils.go @@ -0,0 +1,402 @@ +package generator + +import ( + "fmt" + "path/filepath" + "strings" + + v1 "github.com/devfile/api/pkg/apis/workspaces/v1alpha2" + "github.com/devfile/library/pkg/devfile/parser" + "github.com/devfile/library/pkg/util" + buildv1 "github.com/openshift/api/build/v1" + routev1 "github.com/openshift/api/route/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + extensionsv1 "k8s.io/api/extensions/v1beta1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// convertEnvs converts environment variables from the devfile structure to kubernetes structure +func convertEnvs(vars []v1.EnvVar) []corev1.EnvVar { + kVars := []corev1.EnvVar{} + for _, env := range vars { + kVars = append(kVars, corev1.EnvVar{ + Name: env.Name, + Value: env.Value, + }) + } + return kVars +} + +// convertPorts converts endpoint variables from the devfile structure to kubernetes ContainerPort +func convertPorts(endpoints []v1.Endpoint) []corev1.ContainerPort { + containerPorts := []corev1.ContainerPort{} + for _, endpoint := range endpoints { + name := strings.TrimSpace(util.GetDNS1123Name(strings.ToLower(endpoint.Name))) + name = util.TruncateString(name, 15) + + containerPorts = append(containerPorts, corev1.ContainerPort{ + Name: name, + ContainerPort: int32(endpoint.TargetPort), + }) + } + return containerPorts +} + +// getResourceReqs creates a kubernetes ResourceRequirements object based on resource requirements set in the devfile +func getResourceReqs(comp v1.Component) corev1.ResourceRequirements { + reqs := corev1.ResourceRequirements{} + limits := make(corev1.ResourceList) + if comp.Container != nil && comp.Container.MemoryLimit != "" { + memoryLimit, err := resource.ParseQuantity(comp.Container.MemoryLimit) + if err == nil { + limits[corev1.ResourceMemory] = memoryLimit + } + reqs.Limits = limits + } + return reqs +} + +// addSyncRootFolder adds the sync root folder to the container env +func addSyncRootFolder(container *corev1.Container, sourceMapping string) string { + var syncRootFolder string + if sourceMapping != "" { + syncRootFolder = sourceMapping + } else { + syncRootFolder = DevfileSourceVolumeMount + } + + // Note: PROJECTS_ROOT & PROJECT_SOURCE are validated at the devfile parser level + // Add PROJECTS_ROOT to the container + container.Env = append(container.Env, + corev1.EnvVar{ + Name: EnvProjectsRoot, + Value: syncRootFolder, + }) + + return syncRootFolder +} + +// addSyncFolder adds the sync folder path to the container env +// sourceVolumePath: mount path of the empty dir volume to sync source code +// projects: list of projects from devfile +func addSyncFolder(container *corev1.Container, sourceVolumePath string, projects []v1.Project) error { + var syncFolder string + + // if there are no projects in the devfile, source would be synced to $PROJECTS_ROOT + if len(projects) == 0 { + syncFolder = sourceVolumePath + } else { + // if there is one or more projects in the devfile, get the first project and check its clonepath + project := projects[0] + // If clonepath does not exist source would be synced to $PROJECTS_ROOT/projectName + syncFolder = filepath.ToSlash(filepath.Join(sourceVolumePath, project.Name)) + + if project.ClonePath != "" { + if strings.HasPrefix(project.ClonePath, "/") { + return fmt.Errorf("the clonePath %s in the devfile project %s must be a relative path", project.ClonePath, project.Name) + } + if strings.Contains(project.ClonePath, "..") { + return fmt.Errorf("the clonePath %s in the devfile project %s cannot escape the value defined by $PROJECTS_ROOT. Please avoid using \"..\" in clonePath", project.ClonePath, project.Name) + } + // If clonepath exist source would be synced to $PROJECTS_ROOT/clonePath + syncFolder = filepath.ToSlash(filepath.Join(sourceVolumePath, project.ClonePath)) + } + } + + container.Env = append(container.Env, + corev1.EnvVar{ + Name: EnvProjectsSrc, + Value: syncFolder, + }) + + return nil +} + +// containerParams is a struct that contains the required data to create a container object +type containerParams struct { + Name string + Image string + IsPrivileged bool + Command []string + Args []string + EnvVars []corev1.EnvVar + ResourceReqs corev1.ResourceRequirements + Ports []corev1.ContainerPort +} + +// getContainer gets a container struct that can be used when creating a pod +func getContainer(containerParams containerParams) *corev1.Container { + container := &corev1.Container{ + Name: containerParams.Name, + Image: containerParams.Image, + ImagePullPolicy: corev1.PullAlways, + Resources: containerParams.ResourceReqs, + Env: containerParams.EnvVars, + Ports: containerParams.Ports, + Command: containerParams.Command, + Args: containerParams.Args, + } + + if containerParams.IsPrivileged { + container.SecurityContext = &corev1.SecurityContext{ + Privileged: &containerParams.IsPrivileged, + } + } + + return container +} + +// podTemplateSpecParams is a struct that contains the required data to create a pod template spec object +type podTemplateSpecParams struct { + ObjectMeta metav1.ObjectMeta + InitContainers []corev1.Container + Containers []corev1.Container + Volumes []corev1.Volume +} + +// getPodTemplateSpec gets a pod template spec that can be used to create a deployment spec +func getPodTemplateSpec(podTemplateSpecParams podTemplateSpecParams) *corev1.PodTemplateSpec { + podTemplateSpec := &corev1.PodTemplateSpec{ + ObjectMeta: podTemplateSpecParams.ObjectMeta, + Spec: corev1.PodSpec{ + InitContainers: podTemplateSpecParams.InitContainers, + Containers: podTemplateSpecParams.Containers, + Volumes: podTemplateSpecParams.Volumes, + }, + } + + return podTemplateSpec +} + +// deploymentSpecParams is a struct that contains the required data to create a deployment spec object +type deploymentSpecParams struct { + PodTemplateSpec corev1.PodTemplateSpec + PodSelectorLabels map[string]string +} + +// getDeploymentSpec gets a deployment spec +func getDeploymentSpec(deploySpecParams deploymentSpecParams) *appsv1.DeploymentSpec { + deploymentSpec := &appsv1.DeploymentSpec{ + Strategy: appsv1.DeploymentStrategy{ + Type: appsv1.RecreateDeploymentStrategyType, + }, + Selector: &metav1.LabelSelector{ + MatchLabels: deploySpecParams.PodSelectorLabels, + }, + Template: deploySpecParams.PodTemplateSpec, + } + + return deploymentSpec +} + +// getServiceSpec iterates through the devfile components and returns a ServiceSpec +func getServiceSpec(devfileObj parser.DevfileObj, selectorLabels map[string]string) (*corev1.ServiceSpec, error) { + + var containerPorts []corev1.ContainerPort + portExposureMap := getPortExposure(devfileObj) + containers, err := GetContainers(devfileObj) + if err != nil { + return nil, err + } + for _, c := range containers { + for _, port := range c.Ports { + portExist := false + for _, entry := range containerPorts { + if entry.ContainerPort == port.ContainerPort { + portExist = true + break + } + } + // if Exposure == none, should not create a service for that port + if !portExist && portExposureMap[int(port.ContainerPort)] != v1.NoneEndpointExposure { + port.Name = fmt.Sprintf("port-%v", port.ContainerPort) + containerPorts = append(containerPorts, port) + } + } + } + + var svcPorts []corev1.ServicePort + for _, containerPort := range containerPorts { + svcPort := corev1.ServicePort{ + + Name: containerPort.Name, + Port: containerPort.ContainerPort, + TargetPort: intstr.FromInt(int(containerPort.ContainerPort)), + } + svcPorts = append(svcPorts, svcPort) + } + svcSpec := &corev1.ServiceSpec{ + Ports: svcPorts, + Selector: selectorLabels, + } + + return svcSpec, nil +} + +// getPortExposure iterates through all endpoints and returns the highest exposure level of all TargetPort. +// exposure level: public > internal > none +func getPortExposure(devfileObj parser.DevfileObj) map[int]v1.EndpointExposure { + portExposureMap := make(map[int]v1.EndpointExposure) + containerComponents := devfileObj.Data.GetDevfileContainerComponents() + for _, comp := range containerComponents { + for _, endpoint := range comp.Container.Endpoints { + // if exposure=public, no need to check for existence + if endpoint.Exposure == v1.PublicEndpointExposure || endpoint.Exposure == "" { + portExposureMap[endpoint.TargetPort] = v1.PublicEndpointExposure + } else if exposure, exist := portExposureMap[endpoint.TargetPort]; exist { + // if a container has multiple identical ports with different exposure levels, save the highest level in the map + if endpoint.Exposure == v1.InternalEndpointExposure && exposure == v1.NoneEndpointExposure { + portExposureMap[endpoint.TargetPort] = v1.InternalEndpointExposure + } + } else { + portExposureMap[endpoint.TargetPort] = endpoint.Exposure + } + } + + } + return portExposureMap +} + +// IngressSpecParams struct for function GenerateIngressSpec +// serviceName is the name of the service for the target reference +// ingressDomain is the ingress domain to use for the ingress +// portNumber is the target port of the ingress +// Path is the path of the ingress +// TLSSecretName is the target TLS Secret name of the ingress +type IngressSpecParams struct { + ServiceName string + IngressDomain string + PortNumber intstr.IntOrString + TLSSecretName string + Path string +} + +// getIngressSpec gets an ingress spec +func getIngressSpec(ingressSpecParams IngressSpecParams) *extensionsv1.IngressSpec { + path := "/" + if ingressSpecParams.Path != "" { + path = ingressSpecParams.Path + } + ingressSpec := &extensionsv1.IngressSpec{ + Rules: []extensionsv1.IngressRule{ + { + Host: ingressSpecParams.IngressDomain, + IngressRuleValue: extensionsv1.IngressRuleValue{ + HTTP: &extensionsv1.HTTPIngressRuleValue{ + Paths: []extensionsv1.HTTPIngressPath{ + { + Path: path, + Backend: extensionsv1.IngressBackend{ + ServiceName: ingressSpecParams.ServiceName, + ServicePort: ingressSpecParams.PortNumber, + }, + }, + }, + }, + }, + }, + }, + } + secretNameLength := len(ingressSpecParams.TLSSecretName) + if secretNameLength != 0 { + ingressSpec.TLS = []extensionsv1.IngressTLS{ + { + Hosts: []string{ + ingressSpecParams.IngressDomain, + }, + SecretName: ingressSpecParams.TLSSecretName, + }, + } + } + + return ingressSpec +} + +// RouteSpecParams struct for function GenerateRouteSpec +// serviceName is the name of the service for the target reference +// portNumber is the target port of the ingress +// Path is the path of the route +type RouteSpecParams struct { + ServiceName string + PortNumber intstr.IntOrString + Path string + Secure bool +} + +// GetRouteSpec gets a route spec +func getRouteSpec(routeParams RouteSpecParams) *routev1.RouteSpec { + routePath := "/" + if routeParams.Path != "" { + routePath = routeParams.Path + } + routeSpec := &routev1.RouteSpec{ + To: routev1.RouteTargetReference{ + Kind: "Service", + Name: routeParams.ServiceName, + }, + Port: &routev1.RoutePort{ + TargetPort: routeParams.PortNumber, + }, + Path: routePath, + } + + if routeParams.Secure { + routeSpec.TLS = &routev1.TLSConfig{ + Termination: routev1.TLSTerminationEdge, + InsecureEdgeTerminationPolicy: routev1.InsecureEdgeTerminationPolicyRedirect, + } + } + + return routeSpec +} + +// getPVCSpec gets a RWO pvc spec +func getPVCSpec(quantity resource.Quantity) *corev1.PersistentVolumeClaimSpec { + + pvcSpec := &corev1.PersistentVolumeClaimSpec{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: quantity, + }, + }, + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + }, + } + + return pvcSpec +} + +// BuildConfigSpecParams is a struct to create build config spec +type BuildConfigSpecParams struct { + ImageStreamTagName string + GitURL string + GitRef string + BuildStrategy buildv1.BuildStrategy +} + +// getBuildConfigSpec gets the build config spec and outputs the build to the image stream +func getBuildConfigSpec(buildConfigSpecParams BuildConfigSpecParams) *buildv1.BuildConfigSpec { + + return &buildv1.BuildConfigSpec{ + CommonSpec: buildv1.CommonSpec{ + Output: buildv1.BuildOutput{ + To: &corev1.ObjectReference{ + Kind: "ImageStreamTag", + Name: buildConfigSpecParams.ImageStreamTagName + ":latest", + }, + }, + Source: buildv1.BuildSource{ + Git: &buildv1.GitBuildSource{ + URI: buildConfigSpecParams.GitURL, + Ref: buildConfigSpecParams.GitRef, + }, + Type: buildv1.BuildSourceGit, + }, + Strategy: buildConfigSpecParams.BuildStrategy, + }, + } +} diff --git a/pkg/devfile/generator/utils_test.go b/pkg/devfile/generator/utils_test.go new file mode 100644 index 00000000..13f0aa50 --- /dev/null +++ b/pkg/devfile/generator/utils_test.go @@ -0,0 +1,1133 @@ +package generator + +import ( + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/devfile/library/pkg/devfile/parser" + "github.com/devfile/library/pkg/testingutil" + buildv1 "github.com/openshift/api/build/v1" + + v1 "github.com/devfile/api/pkg/apis/workspaces/v1alpha2" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/util/intstr" +) + +func TestConvertEnvs(t *testing.T) { + envVarsNames := []string{"test", "sample-var", "myvar"} + envVarsValues := []string{"value1", "value2", "value3"} + tests := []struct { + name string + envVars []v1.EnvVar + want []corev1.EnvVar + }{ + { + name: "Case 1: One env var", + envVars: []v1.EnvVar{ + { + Name: envVarsNames[0], + Value: envVarsValues[0], + }, + }, + want: []corev1.EnvVar{ + { + Name: envVarsNames[0], + Value: envVarsValues[0], + }, + }, + }, + { + name: "Case 2: Multiple env vars", + envVars: []v1.EnvVar{ + { + Name: envVarsNames[0], + Value: envVarsValues[0], + }, + { + Name: envVarsNames[1], + Value: envVarsValues[1], + }, + { + Name: envVarsNames[2], + Value: envVarsValues[2], + }, + }, + want: []corev1.EnvVar{ + { + Name: envVarsNames[0], + Value: envVarsValues[0], + }, + { + Name: envVarsNames[1], + Value: envVarsValues[1], + }, + { + Name: envVarsNames[2], + Value: envVarsValues[2], + }, + }, + }, + { + name: "Case 3: No env vars", + envVars: []v1.EnvVar{}, + want: []corev1.EnvVar{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + envVars := convertEnvs(tt.envVars) + if !reflect.DeepEqual(tt.want, envVars) { + t.Errorf("expected %v, wanted %v", envVars, tt.want) + } + }) + } +} + +func TestConvertPorts(t *testing.T) { + endpointsNames := []string{"endpoint1", "endpoint2"} + endpointsPorts := []int{8080, 9090} + tests := []struct { + name string + endpoints []v1.Endpoint + want []corev1.ContainerPort + }{ + { + name: "Case 1: One Endpoint", + endpoints: []v1.Endpoint{ + { + Name: endpointsNames[0], + TargetPort: endpointsPorts[0], + }, + }, + want: []corev1.ContainerPort{ + { + Name: endpointsNames[0], + ContainerPort: int32(endpointsPorts[0]), + }, + }, + }, + { + name: "Case 2: Multiple env vars", + endpoints: []v1.Endpoint{ + { + Name: endpointsNames[0], + TargetPort: endpointsPorts[0], + }, + { + Name: endpointsNames[1], + TargetPort: endpointsPorts[1], + }, + }, + want: []corev1.ContainerPort{ + { + Name: endpointsNames[0], + ContainerPort: int32(endpointsPorts[0]), + }, + { + Name: endpointsNames[1], + ContainerPort: int32(endpointsPorts[1]), + }, + }, + }, + { + name: "Case 3: No endpoints", + endpoints: []v1.Endpoint{}, + want: []corev1.ContainerPort{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ports := convertPorts(tt.endpoints) + if !reflect.DeepEqual(tt.want, ports) { + t.Errorf("expected %v, wanted %v", ports, tt.want) + } + }) + } +} + +func TestGetResourceReqs(t *testing.T) { + limit := "1024Mi" + quantity, err := resource.ParseQuantity(limit) + if err != nil { + t.Errorf("expected %v", err) + } + tests := []struct { + name string + component v1.Component + want corev1.ResourceRequirements + }{ + { + name: "Case 1: One Endpoint", + component: v1.Component{ + Name: "testcomponent", + ComponentUnion: v1.ComponentUnion{ + Container: &v1.ContainerComponent{ + Container: v1.Container{ + MemoryLimit: "1024Mi", + }, + }, + }, + }, + want: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceMemory: quantity, + }, + }, + }, + { + name: "Case 2: Empty Component", + component: v1.Component{}, + want: corev1.ResourceRequirements{}, + }, + { + name: "Case 3: Valid container, but empty memoryLimit", + component: v1.Component{ + Name: "testcomponent", + ComponentUnion: v1.ComponentUnion{ + Container: &v1.ContainerComponent{ + Container: v1.Container{ + Image: "testimage", + }, + }, + }, + }, + want: corev1.ResourceRequirements{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := getResourceReqs(tt.component) + if !reflect.DeepEqual(tt.want, req) { + t.Errorf("expected %v, wanted %v", req, tt.want) + } + }) + } +} + +func TestAddSyncRootFolder(t *testing.T) { + + tests := []struct { + name string + sourceMapping string + wantSyncRootFolder string + }{ + { + name: "Case 1: Valid Source Mapping", + sourceMapping: "/mypath", + wantSyncRootFolder: "/mypath", + }, + { + name: "Case 2: No Source Mapping", + sourceMapping: "", + wantSyncRootFolder: DevfileSourceVolumeMount, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + container := testingutil.CreateFakeContainer("container1") + + syncRootFolder := addSyncRootFolder(&container, tt.sourceMapping) + + if syncRootFolder != tt.wantSyncRootFolder { + t.Errorf("TestAddSyncRootFolder sync root folder error - expected %v got %v", tt.wantSyncRootFolder, syncRootFolder) + } + + for _, env := range container.Env { + if env.Name == EnvProjectsRoot && env.Value != tt.wantSyncRootFolder { + t.Errorf("PROJECT_ROOT error expected %s, actual %s", tt.wantSyncRootFolder, env.Value) + } + } + }) + } +} + +func TestAddSyncFolder(t *testing.T) { + projectNames := []string{"some-name", "another-name"} + projectRepos := []string{"https://github.com/some/repo.git", "https://github.com/another/repo.git"} + projectClonePath := "src/github.com/golang/example/" + invalidClonePaths := []string{"/var", "../var", "pkg/../../var"} + sourceVolumePath := "/projects/app" + + tests := []struct { + name string + projects []v1.Project + want string + wantErr bool + }{ + { + name: "Case 1: No projects", + projects: []v1.Project{}, + want: sourceVolumePath, + wantErr: false, + }, + { + name: "Case 2: One project", + projects: []v1.Project{ + { + Name: projectNames[0], + ProjectSource: v1.ProjectSource{ + Git: &v1.GitProjectSource{ + GitLikeProjectSource: v1.GitLikeProjectSource{ + Remotes: map[string]string{"origin": projectRepos[0]}, + }, + }, + }, + }, + }, + want: filepath.ToSlash(filepath.Join(sourceVolumePath, projectNames[0])), + wantErr: false, + }, + { + name: "Case 3: Multiple projects", + projects: []v1.Project{ + { + Name: projectNames[0], + ProjectSource: v1.ProjectSource{ + Git: &v1.GitProjectSource{ + GitLikeProjectSource: v1.GitLikeProjectSource{ + Remotes: map[string]string{"origin": projectRepos[0]}, + }, + }, + }, + }, + { + Name: projectNames[1], + ProjectSource: v1.ProjectSource{ + Github: &v1.GithubProjectSource{ + GitLikeProjectSource: v1.GitLikeProjectSource{ + Remotes: map[string]string{"origin": projectRepos[0]}, + }, + }, + }, + }, + { + Name: projectNames[1], + ProjectSource: v1.ProjectSource{ + Zip: &v1.ZipProjectSource{ + Location: projectRepos[1], + }, + }, + }, + }, + want: filepath.ToSlash(filepath.Join(sourceVolumePath, projectNames[0])), + wantErr: false, + }, + { + name: "Case 4: Clone path set", + projects: []v1.Project{ + { + ClonePath: projectClonePath, + Name: projectNames[0], + ProjectSource: v1.ProjectSource{ + Zip: &v1.ZipProjectSource{ + Location: projectRepos[0], + }, + }, + }, + }, + want: filepath.ToSlash(filepath.Join(sourceVolumePath, projectClonePath)), + wantErr: false, + }, + { + name: "Case 5: Invalid clone path, set with absolute path", + projects: []v1.Project{ + { + ClonePath: invalidClonePaths[0], + Name: projectNames[0], + ProjectSource: v1.ProjectSource{ + Github: &v1.GithubProjectSource{ + GitLikeProjectSource: v1.GitLikeProjectSource{ + Remotes: map[string]string{"origin": projectRepos[0]}, + }, + }, + }, + }, + }, + want: "", + wantErr: true, + }, + { + name: "Case 6: Invalid clone path, starts with ..", + projects: []v1.Project{ + { + ClonePath: invalidClonePaths[1], + Name: projectNames[0], + ProjectSource: v1.ProjectSource{ + Git: &v1.GitProjectSource{ + GitLikeProjectSource: v1.GitLikeProjectSource{ + Remotes: map[string]string{"origin": projectRepos[0]}, + }, + }, + }, + }, + }, + want: "", + wantErr: true, + }, + { + name: "Case 7: Invalid clone path, contains ..", + projects: []v1.Project{ + { + ClonePath: invalidClonePaths[2], + Name: projectNames[0], + ProjectSource: v1.ProjectSource{ + Zip: &v1.ZipProjectSource{ + Location: projectRepos[0], + }, + }, + }, + }, + want: "", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + container := testingutil.CreateFakeContainer("container1") + + err := addSyncFolder(&container, sourceVolumePath, tt.projects) + + if !tt.wantErr == (err != nil) { + t.Errorf("expected %v, actual %v", tt.wantErr, err) + } + + for _, env := range container.Env { + if env.Name == EnvProjectsSrc && env.Value != tt.want { + t.Errorf("expected %s, actual %s", tt.want, env.Value) + } + } + }) + } +} + +func TestGetContainer(t *testing.T) { + + tests := []struct { + name string + containerName string + image string + isPrivileged bool + command []string + args []string + envVars []corev1.EnvVar + resourceReqs corev1.ResourceRequirements + ports []corev1.ContainerPort + }{ + { + name: "Case 1: Empty container params", + containerName: "", + image: "", + isPrivileged: false, + command: []string{}, + args: []string{}, + envVars: []corev1.EnvVar{}, + resourceReqs: corev1.ResourceRequirements{}, + ports: []corev1.ContainerPort{}, + }, + { + name: "Case 2: Valid container params", + containerName: "container1", + image: "quay.io/eclipse/che-java8-maven:nightly", + isPrivileged: true, + command: []string{"tail"}, + args: []string{"-f", "/dev/null"}, + envVars: []corev1.EnvVar{ + { + Name: "test", + Value: "123", + }, + }, + resourceReqs: fakeResources, + ports: []corev1.ContainerPort{ + { + Name: "port-9090", + ContainerPort: 9090, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + containerParams := containerParams{ + Name: tt.containerName, + Image: tt.image, + IsPrivileged: tt.isPrivileged, + Command: tt.command, + Args: tt.args, + EnvVars: tt.envVars, + ResourceReqs: tt.resourceReqs, + Ports: tt.ports, + } + container := getContainer(containerParams) + + if container.Name != tt.containerName { + t.Errorf("expected %s, actual %s", tt.containerName, container.Name) + } + + if container.Image != tt.image { + t.Errorf("expected %s, actual %s", tt.image, container.Image) + } + + if tt.isPrivileged { + if *container.SecurityContext.Privileged != tt.isPrivileged { + t.Errorf("expected %t, actual %t", tt.isPrivileged, *container.SecurityContext.Privileged) + } + } else if tt.isPrivileged == false && container.SecurityContext != nil { + t.Errorf("expected security context to be nil but it was defined") + } + + if len(container.Command) != len(tt.command) { + t.Errorf("expected %d, actual %d", len(tt.command), len(container.Command)) + } else { + for i := range container.Command { + if container.Command[i] != tt.command[i] { + t.Errorf("expected %s, actual %s", tt.command[i], container.Command[i]) + } + } + } + + if len(container.Args) != len(tt.args) { + t.Errorf("expected %d, actual %d", len(tt.args), len(container.Args)) + } else { + for i := range container.Args { + if container.Args[i] != tt.args[i] { + t.Errorf("expected %s, actual %s", tt.args[i], container.Args[i]) + } + } + } + + if len(container.Env) != len(tt.envVars) { + t.Errorf("expected %d, actual %d", len(tt.envVars), len(container.Env)) + } else { + for i := range container.Env { + if container.Env[i].Name != tt.envVars[i].Name { + t.Errorf("expected name %s, actual name %s", tt.envVars[i].Name, container.Env[i].Name) + } + if container.Env[i].Value != tt.envVars[i].Value { + t.Errorf("expected value %s, actual value %s", tt.envVars[i].Value, container.Env[i].Value) + } + } + } + + if len(container.Ports) != len(tt.ports) { + t.Errorf("expected %d, actual %d", len(tt.ports), len(container.Ports)) + } else { + for i := range container.Ports { + if container.Ports[i].Name != tt.ports[i].Name { + t.Errorf("expected name %s, actual name %s", tt.ports[i].Name, container.Ports[i].Name) + } + if container.Ports[i].ContainerPort != tt.ports[i].ContainerPort { + t.Errorf("expected port number is %v, actual %v", tt.ports[i].ContainerPort, container.Ports[i].ContainerPort) + } + } + } + + }) + } +} + +func TestGetPodTemplateSpec(t *testing.T) { + + container := []corev1.Container{ + { + Name: "container1", + Image: "image1", + ImagePullPolicy: corev1.PullAlways, + + Command: []string{"tail"}, + Args: []string{"-f", "/dev/null"}, + Env: []corev1.EnvVar{}, + }, + } + + volume := []corev1.Volume{ + { + Name: "vol1", + }, + } + + tests := []struct { + podName string + namespace string + serviceAccount string + labels map[string]string + }{ + { + podName: "podSpecTest", + namespace: "default", + serviceAccount: "default", + labels: map[string]string{ + "app": "app", + "component": "frontend", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.podName, func(t *testing.T) { + + objectMeta := GetObjectMeta(tt.podName, tt.namespace, tt.labels, nil) + podTemplateSpecParams := podTemplateSpecParams{ + ObjectMeta: objectMeta, + Containers: container, + Volumes: volume, + InitContainers: container, + } + podTemplateSpec := getPodTemplateSpec(podTemplateSpecParams) + + if podTemplateSpec.Name != tt.podName { + t.Errorf("expected %s, actual %s", tt.podName, podTemplateSpec.Name) + } + if podTemplateSpec.Namespace != tt.namespace { + t.Errorf("expected %s, actual %s", tt.namespace, podTemplateSpec.Namespace) + } + if !hasVolumeWithName("vol1", podTemplateSpec.Spec.Volumes) { + t.Errorf("volume with name: %s not found", "vol1") + } + if !reflect.DeepEqual(podTemplateSpec.Labels, tt.labels) { + t.Errorf("expected %+v, actual %+v", tt.labels, podTemplateSpec.Labels) + } + if !reflect.DeepEqual(podTemplateSpec.Spec.Containers, container) { + t.Errorf("expected %+v, actual %+v", container, podTemplateSpec.Spec.Containers) + } + if !reflect.DeepEqual(podTemplateSpec.Spec.InitContainers, container) { + t.Errorf("expected %+v, actual %+v", container, podTemplateSpec.Spec.InitContainers) + } + }) + } +} + +func TestGetServiceSpec(t *testing.T) { + + endpointNames := []string{"port-8080-1", "port-8080-2", "port-9090"} + + tests := []struct { + name string + containerComponents []v1.Component + labels map[string]string + wantPorts []corev1.ServicePort + wantErr bool + }{ + { + name: "Case 1: multiple endpoints share the same port", + containerComponents: []v1.Component{ + { + Name: "testcontainer1", + ComponentUnion: v1.ComponentUnion{ + Container: &v1.ContainerComponent{ + Endpoints: []v1.Endpoint{ + { + Name: endpointNames[0], + TargetPort: 8080, + }, + { + Name: endpointNames[1], + TargetPort: 8080, + }, + }, + }, + }, + }, + }, + labels: map[string]string{}, + wantPorts: []corev1.ServicePort{ + { + Name: "port-8080", + Port: 8080, + TargetPort: intstr.FromInt(8080), + }, + }, + wantErr: false, + }, + { + name: "Case 2: multiple endpoints have different ports", + containerComponents: []v1.Component{ + { + Name: "testcontainer1", + ComponentUnion: v1.ComponentUnion{ + Container: &v1.ContainerComponent{ + Endpoints: []v1.Endpoint{ + { + Name: endpointNames[0], + TargetPort: 8080, + }, + { + Name: endpointNames[2], + TargetPort: 9090, + }, + }, + }, + }, + }, + }, + labels: map[string]string{ + "component": "testcomponent", + }, + wantPorts: []corev1.ServicePort{ + { + Name: "port-8080", + Port: 8080, + TargetPort: intstr.FromInt(8080), + }, + { + Name: "port-9090", + Port: 9090, + TargetPort: intstr.FromInt(9090), + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + devObj := parser.DevfileObj{ + Data: &testingutil.TestDevfileData{ + Components: tt.containerComponents, + }, + } + + serviceSpec, err := getServiceSpec(devObj, tt.labels) + + // Unexpected error + if (err != nil) != tt.wantErr { + t.Errorf("TestGetServiceSpec() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // Expected error and got an err + if tt.wantErr && err != nil { + return + } + + if !reflect.DeepEqual(serviceSpec.Selector, tt.labels) { + t.Errorf("expected service selector is %v, actual %v", tt.labels, serviceSpec.Selector) + } + if len(serviceSpec.Ports) != len(tt.wantPorts) { + t.Errorf("expected service ports length is %v, actual %v", len(tt.wantPorts), len(serviceSpec.Ports)) + } else { + for i := range serviceSpec.Ports { + if serviceSpec.Ports[i].Name != tt.wantPorts[i].Name { + t.Errorf("expected name %s, actual name %s", tt.wantPorts[i].Name, serviceSpec.Ports[i].Name) + } + if serviceSpec.Ports[i].Port != tt.wantPorts[i].Port { + t.Errorf("expected port number is %v, actual %v", tt.wantPorts[i].Port, serviceSpec.Ports[i].Port) + } + } + } + }) + } +} + +func TestGetPortExposure(t *testing.T) { + urlName := "testurl" + urlName2 := "testurl2" + tests := []struct { + name string + containerComponents []v1.Component + wantMap map[int]v1.EndpointExposure + wantErr bool + }{ + { + name: "Case 1: devfile has single container with single endpoint", + wantMap: map[int]v1.EndpointExposure{ + 8080: v1.PublicEndpointExposure, + }, + containerComponents: []v1.Component{ + { + Name: "testcontainer1", + ComponentUnion: v1.ComponentUnion{ + Container: &v1.ContainerComponent{ + Container: v1.Container{ + Image: "image", + }, + Endpoints: []v1.Endpoint{ + { + Name: urlName, + TargetPort: 8080, + Exposure: v1.PublicEndpointExposure, + }, + }, + }, + }, + }, + }, + }, + { + name: "Case 2: devfile no endpoints", + wantMap: map[int]v1.EndpointExposure{}, + containerComponents: []v1.Component{ + { + Name: "testcontainer1", + ComponentUnion: v1.ComponentUnion{ + Container: &v1.ContainerComponent{ + Container: v1.Container{ + Image: "image", + }, + }, + }, + }, + }, + }, + { + name: "Case 3: devfile has multiple endpoints with same port, 1 public and 1 internal, should assign public", + wantMap: map[int]v1.EndpointExposure{ + 8080: v1.PublicEndpointExposure, + }, + containerComponents: []v1.Component{ + { + Name: "testcontainer1", + ComponentUnion: v1.ComponentUnion{ + Container: &v1.ContainerComponent{ + Container: v1.Container{ + Image: "image", + }, + Endpoints: []v1.Endpoint{ + { + Name: urlName, + TargetPort: 8080, + Exposure: v1.PublicEndpointExposure, + }, + { + Name: urlName, + TargetPort: 8080, + Exposure: v1.InternalEndpointExposure, + }, + }, + }, + }, + }, + }, + }, + { + name: "Case 4: devfile has multiple endpoints with same port, 1 public and 1 none, should assign public", + wantMap: map[int]v1.EndpointExposure{ + 8080: v1.PublicEndpointExposure, + }, + containerComponents: []v1.Component{ + { + Name: "testcontainer1", + ComponentUnion: v1.ComponentUnion{ + Container: &v1.ContainerComponent{ + Container: v1.Container{ + Image: "image", + }, + Endpoints: []v1.Endpoint{ + { + Name: urlName, + TargetPort: 8080, + Exposure: v1.PublicEndpointExposure, + }, + { + Name: urlName, + TargetPort: 8080, + Exposure: v1.NoneEndpointExposure, + }, + }, + }, + }, + }, + }, + }, + { + name: "Case 5: devfile has multiple endpoints with same port, 1 internal and 1 none, should assign internal", + wantMap: map[int]v1.EndpointExposure{ + 8080: v1.InternalEndpointExposure, + }, + containerComponents: []v1.Component{ + { + Name: "testcontainer1", + ComponentUnion: v1.ComponentUnion{ + Container: &v1.ContainerComponent{ + Container: v1.Container{ + Image: "image", + }, + Endpoints: []v1.Endpoint{ + { + Name: urlName, + TargetPort: 8080, + Exposure: v1.InternalEndpointExposure, + }, + { + Name: urlName, + TargetPort: 8080, + Exposure: v1.NoneEndpointExposure, + }, + }, + }, + }, + }, + }, + }, + { + name: "Case 6: devfile has multiple endpoints with different port", + wantMap: map[int]v1.EndpointExposure{ + 8080: v1.PublicEndpointExposure, + 9090: v1.InternalEndpointExposure, + 3000: v1.NoneEndpointExposure, + }, + containerComponents: []v1.Component{ + { + Name: "testcontainer1", + ComponentUnion: v1.ComponentUnion{ + Container: &v1.ContainerComponent{ + Container: v1.Container{ + Image: "image", + }, + Endpoints: []v1.Endpoint{ + { + Name: urlName, + TargetPort: 8080, + }, + { + Name: urlName, + TargetPort: 3000, + Exposure: v1.NoneEndpointExposure, + }, + }, + }, + }, + }, + { + Name: "testcontainer2", + ComponentUnion: v1.ComponentUnion{ + Container: &v1.ContainerComponent{ + Container: v1.Container{ + Image: "image", + }, + Endpoints: []v1.Endpoint{ + { + Name: urlName2, + TargetPort: 9090, + Secure: true, + Path: "/testpath", + Exposure: v1.InternalEndpointExposure, + Protocol: v1.HTTPSEndpointProtocol, + }, + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + devObj := parser.DevfileObj{ + Data: &testingutil.TestDevfileData{ + Components: tt.containerComponents, + }, + } + mapCreated := getPortExposure(devObj) + if !reflect.DeepEqual(mapCreated, tt.wantMap) { + t.Errorf("Expected: %v, got %v", tt.wantMap, mapCreated) + } + + }) + } + +} + +func TestGenerateIngressSpec(t *testing.T) { + + tests := []struct { + name string + parameter IngressSpecParams + }{ + { + name: "1", + parameter: IngressSpecParams{ + ServiceName: "service1", + IngressDomain: "test.1.2.3.4.nip.io", + PortNumber: intstr.IntOrString{ + IntVal: 8080, + }, + TLSSecretName: "testTLSSecret", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + ingressSpec := getIngressSpec(tt.parameter) + + if ingressSpec.Rules[0].Host != tt.parameter.IngressDomain { + t.Errorf("expected %s, actual %s", tt.parameter.IngressDomain, ingressSpec.Rules[0].Host) + } + + if ingressSpec.Rules[0].HTTP.Paths[0].Backend.ServicePort != tt.parameter.PortNumber { + t.Errorf("expected %v, actual %v", tt.parameter.PortNumber, ingressSpec.Rules[0].HTTP.Paths[0].Backend.ServicePort) + } + + if ingressSpec.Rules[0].HTTP.Paths[0].Backend.ServiceName != tt.parameter.ServiceName { + t.Errorf("expected %s, actual %s", tt.parameter.ServiceName, ingressSpec.Rules[0].HTTP.Paths[0].Backend.ServiceName) + } + + if ingressSpec.TLS[0].SecretName != tt.parameter.TLSSecretName { + t.Errorf("expected %s, actual %s", tt.parameter.TLSSecretName, ingressSpec.TLS[0].SecretName) + } + + }) + } +} + +func TestGetRouteSpec(t *testing.T) { + + tests := []struct { + name string + parameter RouteSpecParams + }{ + { + name: "Case 1: insecure route", + parameter: RouteSpecParams{ + ServiceName: "service1", + PortNumber: intstr.IntOrString{ + IntVal: 8080, + }, + Secure: false, + Path: "/test", + }, + }, + { + name: "Case 2: secure route", + parameter: RouteSpecParams{ + ServiceName: "service1", + PortNumber: intstr.IntOrString{ + IntVal: 8080, + }, + Secure: true, + Path: "/test", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + routeSpec := getRouteSpec(tt.parameter) + + if routeSpec.Port.TargetPort != tt.parameter.PortNumber { + t.Errorf("expected %v, actual %v", tt.parameter.PortNumber, routeSpec.Port.TargetPort) + } + + if routeSpec.To.Name != tt.parameter.ServiceName { + t.Errorf("expected %s, actual %s", tt.parameter.ServiceName, routeSpec.To.Name) + } + + if routeSpec.Path != tt.parameter.Path { + t.Errorf("expected %s, actual %s", tt.parameter.Path, routeSpec.Path) + } + + if (routeSpec.TLS != nil) != tt.parameter.Secure { + t.Errorf("the route TLS does not match secure level %v", tt.parameter.Secure) + } + + }) + } +} + +func TestGetPVCSpec(t *testing.T) { + + tests := []struct { + name string + size string + wantErr bool + }{ + { + name: "Case 1: Valid resource size", + size: "1Gi", + wantErr: false, + }, + { + name: "Case 2: Resource size missing", + size: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + quantity, err := resource.ParseQuantity(tt.size) + // Checks for unexpected error cases + if !tt.wantErr == (err != nil) { + t.Errorf("resource.ParseQuantity unexpected error %v, wantErr %v", err, tt.wantErr) + } + + pvcSpec := getPVCSpec(quantity) + if pvcSpec.AccessModes[0] != corev1.ReadWriteOnce { + t.Errorf("AccessMode Error: expected %s, actual %s", corev1.ReadWriteMany, pvcSpec.AccessModes[0]) + } + + pvcSpecQuantity := pvcSpec.Resources.Requests["storage"] + if pvcSpecQuantity.String() != quantity.String() { + t.Errorf("pvcSpec.Resources.Requests Error: expected %v, actual %v", pvcSpecQuantity.String(), quantity.String()) + } + }) + } +} + +func hasVolumeWithName(name string, volMounts []corev1.Volume) bool { + for _, vm := range volMounts { + if vm.Name == name { + return true + } + } + return false +} + +func TestGetBuildConfigSpec(t *testing.T) { + + image := "image" + namespace := "namespace" + + tests := []struct { + name string + GitURL string + GitRef string + buildStrategy buildv1.BuildStrategy + }{ + { + name: "Case 1: Get a Source Strategy Build Config", + GitURL: "url", + GitRef: "ref", + buildStrategy: GetSourceBuildStrategy(image, namespace), + }, + { + name: "Case 2: Get a Docker Strategy Build Config", + GitURL: "url", + GitRef: "ref", + buildStrategy: GetDockerBuildStrategy("dockerfilePath", []corev1.EnvVar{}), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + commonObjectMeta := GetObjectMeta(image, namespace, nil, nil) + params := BuildConfigSpecParams{ + ImageStreamTagName: commonObjectMeta.Name, + BuildStrategy: tt.buildStrategy, + GitURL: tt.GitURL, + GitRef: tt.GitRef, + } + buildConfigSpec := getBuildConfigSpec(params) + + if !strings.Contains(buildConfigSpec.CommonSpec.Output.To.Name, image) { + t.Error("TestGetBuildConfigSpec error - build config output name does not match") + } + + if buildConfigSpec.Source.Git.Ref != tt.GitRef || buildConfigSpec.Source.Git.URI != tt.GitURL { + t.Error("TestGetBuildConfigSpec error - build config git source does not match") + } + }) + } + +} diff --git a/pkg/devfile/parse.go b/pkg/devfile/parse.go index dc732161..b420e42d 100644 --- a/pkg/devfile/parse.go +++ b/pkg/devfile/parse.go @@ -16,8 +16,7 @@ func ParseFromURLAndValidate(url string) (d parser.DevfileObj, err error) { return d, err } - // odo specific validation on devfile content - // err = validate.ValidateDevfileData(d.Data) + // generic validation on devfile content - TODO return d, err } @@ -33,8 +32,7 @@ func ParseFromDataAndValidate(data []byte) (d parser.DevfileObj, err error) { return d, err } - // odo specific validation on devfile content - // err = validate.ValidateDevfileData(d.Data) + // generic validation on devfile content - TODO return d, err } @@ -51,8 +49,7 @@ func ParseAndValidate(path string) (d parser.DevfileObj, err error) { return d, err } - // odo specific validation on devfile content - // err = validate.ValidateDevfileData(d.Data) + // generic validation on devfile content - TODO return d, err } diff --git a/pkg/devfile/parser/context/apiVersion.go b/pkg/devfile/parser/context/apiVersion.go index 425a5157..2f05a209 100644 --- a/pkg/devfile/parser/context/apiVersion.go +++ b/pkg/devfile/parser/context/apiVersion.go @@ -56,7 +56,7 @@ func (d *DevfileCtx) GetApiVersion() string { return d.apiVersion } -// IsApiVersionSupported return true if the apiVersion in DevfileCtx is supported in odo +// IsApiVersionSupported return true if the apiVersion in DevfileCtx is supported func (d *DevfileCtx) IsApiVersionSupported() bool { return data.IsApiVersionSupported(d.apiVersion) } diff --git a/pkg/devfile/parser/data/helper.go b/pkg/devfile/parser/data/helper.go index 30c4a0dc..190acbc2 100644 --- a/pkg/devfile/parser/data/helper.go +++ b/pkg/devfile/parser/data/helper.go @@ -38,13 +38,13 @@ func GetDevfileJSONSchema(version string) (string, error) { } return "", fmt.Errorf("unable to find schema for version %q. The parser supports devfile schema for version %s", version, strings.Join(supportedVersions, ", ")) } - klog.V(4).Infof("devfile apiVersion '%s' is supported in odo", version) + klog.V(4).Infof("devfile apiVersion '%s' is supported", version) // Successful return schema, nil } -// IsApiVersionSupported returns true if the API version is supported in odo +// IsApiVersionSupported returns true if the API version is supported func IsApiVersionSupported(version string) bool { return apiVersionToDevfileStruct[supportedApiVersion(version)] != nil } diff --git a/pkg/devfile/parser/data/interface.go b/pkg/devfile/parser/data/interface.go index 84d0d8c0..8bfd76dd 100644 --- a/pkg/devfile/parser/data/interface.go +++ b/pkg/devfile/parser/data/interface.go @@ -7,6 +7,7 @@ import ( // DevfileData is an interface that defines functions for Devfile data operations type DevfileData interface { + GetSchemaVersion() string SetSchemaVersion(version string) GetMetadata() devfilepkg.DevfileMetadata SetMetadata(name, version string) @@ -44,4 +45,8 @@ type DevfileData interface { AddVolume(volume v1.Component, path string) error DeleteVolume(name string) error GetVolumeMountPath(name string) (string, error) + + //utils + GetDevfileContainerComponents() []v1.Component + GetDevfileVolumeComponents() []v1.Component } diff --git a/pkg/devfile/parser/data/v2/components.go b/pkg/devfile/parser/data/v2/components.go index 15cc6b59..576634d3 100644 --- a/pkg/devfile/parser/data/v2/components.go +++ b/pkg/devfile/parser/data/v2/components.go @@ -10,6 +10,28 @@ func (d *DevfileV2) GetComponents() []v1.Component { return d.Components } +// GetDevfileContainerComponents iterates through the components in the devfile and returns a list of devfile container components +func (d *DevfileV2) GetDevfileContainerComponents() []v1.Component { + var components []v1.Component + for _, comp := range d.GetComponents() { + if comp.Container != nil { + components = append(components, comp) + } + } + return components +} + +// GetDevfileVolumeComponents iterates through the components in the devfile and returns a list of devfile volume components +func (d *DevfileV2) GetDevfileVolumeComponents() []v1.Component { + var components []v1.Component + for _, comp := range d.GetComponents() { + if comp.Volume != nil { + components = append(components, comp) + } + } + return components +} + // AddComponents adds the slice of Component objects to the devfile's components // if a component is already defined, error out func (d *DevfileV2) AddComponents(components []v1.Component) error { diff --git a/pkg/devfile/parser/data/v2/components_test.go b/pkg/devfile/parser/data/v2/components_test.go index c9d0910a..163a3700 100644 --- a/pkg/devfile/parser/data/v2/components_test.go +++ b/pkg/devfile/parser/data/v2/components_test.go @@ -5,6 +5,7 @@ import ( "testing" v1 "github.com/devfile/api/pkg/apis/workspaces/v1alpha2" + "github.com/devfile/library/pkg/testingutil" ) func TestDevfile200_AddComponent(t *testing.T) { @@ -167,3 +168,110 @@ func TestDevfile200_UpdateComponent(t *testing.T) { }) } } + +func TestGetDevfileContainerComponents(t *testing.T) { + + tests := []struct { + name string + component []v1.Component + expectedMatchesCount int + }{ + { + name: "Case 1: Invalid devfile", + component: []v1.Component{}, + expectedMatchesCount: 0, + }, + { + name: "Case 2: Valid devfile with wrong component type (Openshift)", + component: []v1.Component{ + { + ComponentUnion: v1.ComponentUnion{ + Openshift: &v1.OpenshiftComponent{}, + }, + }, + }, + expectedMatchesCount: 0, + }, + { + name: "Case 3 : Valid devfile with correct component type (Container)", + component: []v1.Component{ + testingutil.GetFakeContainerComponent("comp1"), + testingutil.GetFakeContainerComponent("comp2"), + }, + expectedMatchesCount: 2, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &DevfileV2{ + v1.Devfile{ + DevWorkspaceTemplateSpec: v1.DevWorkspaceTemplateSpec{ + DevWorkspaceTemplateSpecContent: v1.DevWorkspaceTemplateSpecContent{ + Components: tt.component, + }, + }, + }, + } + + devfileComponents := d.GetDevfileContainerComponents() + + if len(devfileComponents) != tt.expectedMatchesCount { + t.Errorf("TestGetDevfileContainerComponents error: wrong number of components matched: expected %v, actual %v", tt.expectedMatchesCount, len(devfileComponents)) + } + }) + } + +} + +func TestGetDevfileVolumeComponents(t *testing.T) { + + tests := []struct { + name string + component []v1.Component + expectedMatchesCount int + }{ + { + name: "Case 1: Invalid devfile", + component: []v1.Component{}, + expectedMatchesCount: 0, + }, + { + name: "Case 2: Valid devfile with wrong component type (Kubernetes)", + component: []v1.Component{ + { + ComponentUnion: v1.ComponentUnion{ + Kubernetes: &v1.KubernetesComponent{}, + }, + }, + }, + expectedMatchesCount: 0, + }, + { + name: "Case 3: Valid devfile with correct component type (Volume)", + component: []v1.Component{ + testingutil.GetFakeContainerComponent("comp1"), + testingutil.GetFakeVolumeComponent("myvol", "4Gi"), + }, + expectedMatchesCount: 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &DevfileV2{ + v1.Devfile{ + DevWorkspaceTemplateSpec: v1.DevWorkspaceTemplateSpec{ + DevWorkspaceTemplateSpecContent: v1.DevWorkspaceTemplateSpecContent{ + Components: tt.component, + }, + }, + }, + } + devfileComponents := d.GetDevfileVolumeComponents() + + if len(devfileComponents) != tt.expectedMatchesCount { + t.Errorf("TestGetDevfileVolumeComponents error: wrong number of components matched: expected %v, actual %v", tt.expectedMatchesCount, len(devfileComponents)) + } + }) + } + +} diff --git a/pkg/devfile/parser/data/v2/header.go b/pkg/devfile/parser/data/v2/header.go index 3d44688c..5f79866c 100644 --- a/pkg/devfile/parser/data/v2/header.go +++ b/pkg/devfile/parser/data/v2/header.go @@ -4,6 +4,11 @@ import ( devfilepkg "github.com/devfile/api/pkg/devfile" ) +//GetSchemaVersion gets devfile schema version +func (d *DevfileV2) GetSchemaVersion() string { + return d.SchemaVersion +} + //SetSchemaVersion sets devfile schema version func (d *DevfileV2) SetSchemaVersion(version string) { d.SchemaVersion = version diff --git a/pkg/devfile/parser/data/v2/header_test.go b/pkg/devfile/parser/data/v2/header_test.go index da1f9051..5310543a 100644 --- a/pkg/devfile/parser/data/v2/header_test.go +++ b/pkg/devfile/parser/data/v2/header_test.go @@ -8,6 +8,38 @@ import ( devfilepkg "github.com/devfile/api/pkg/devfile" ) +func TestDevfile200_GetSchemaVersion(t *testing.T) { + + type args struct { + name string + } + tests := []struct { + name string + expectedSchemaVersion string + devfilev2 *DevfileV2 + }{ + { + name: "case 1: Get the schema version", + devfilev2: &DevfileV2{ + v1.Devfile{ + DevfileHeader: devfilepkg.DevfileHeader{ + SchemaVersion: "1.0.0", + }, + }, + }, + expectedSchemaVersion: "1.0.0", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + version := tt.devfilev2.GetSchemaVersion() + if version != tt.expectedSchemaVersion { + t.Errorf("TestDevfile200_GetSchemaVersion error - schema version did not match. Expected %s, got %s", tt.expectedSchemaVersion, version) + } + }) + } +} + func TestDevfile200_SetSchemaVersion(t *testing.T) { type args struct { diff --git a/pkg/devfile/parser/data/versions.go b/pkg/devfile/parser/data/versions.go index b0d48c78..f48c26e2 100644 --- a/pkg/devfile/parser/data/versions.go +++ b/pkg/devfile/parser/data/versions.go @@ -10,7 +10,7 @@ import ( // SupportedApiVersions stores the supported devfile API versions type supportedApiVersion string -// Supported devfile API versions in odo +// Supported devfile API versions const ( APIVersion200 supportedApiVersion = "2.0.0" ) diff --git a/pkg/devfile/validate/validate.go b/pkg/devfile/validate/validate.go index cca7ccbf..8768d75d 100644 --- a/pkg/devfile/validate/validate.go +++ b/pkg/devfile/validate/validate.go @@ -4,7 +4,7 @@ import ( "k8s.io/klog" ) -// ValidateDevfileData validates whether sections of devfile are odo compatible +// ValidateDevfileData validates whether sections of devfile are compatible func ValidateDevfileData(data interface{}) error { // Skipped diff --git a/pkg/testingutil/containers.go b/pkg/testingutil/containers.go new file mode 100644 index 00000000..3c9433bb --- /dev/null +++ b/pkg/testingutil/containers.go @@ -0,0 +1,10 @@ +package testingutil + +import corev1 "k8s.io/api/core/v1" + +// CreateFakeContainer creates a container with the given containerName +func CreateFakeContainer(containerName string) corev1.Container { + return corev1.Container{ + Name: containerName, + } +} diff --git a/pkg/testingutil/devfile.go b/pkg/testingutil/devfile.go index ded6ba49..f05bfb45 100644 --- a/pkg/testingutil/devfile.go +++ b/pkg/testingutil/devfile.go @@ -1,6 +1,7 @@ package testingutil import ( + "fmt" "strings" v1 "github.com/devfile/api/pkg/apis/workspaces/v1alpha2" @@ -16,30 +17,54 @@ type TestDevfileData struct { Events v1.Events } -// GetComponents is a mock function to get the components from a devfile -func (d TestDevfileData) GetComponents() []v1.Component { - return d.Components -} - // GetMetadata is a mock function to get metadata from devfile func (d TestDevfileData) GetMetadata() devfilepkg.DevfileMetadata { return devfilepkg.DevfileMetadata{} } +// SetMetadata sets metadata for the test devfile +func (d TestDevfileData) SetMetadata(name, version string) {} + +// GetSchemaVersion gets the schema version for the test devfile +func (d TestDevfileData) GetSchemaVersion() string { return "testSchema" } + +// SetSchemaVersion sets the schema version for the test devfile +func (d TestDevfileData) SetSchemaVersion(version string) {} + +// GetParent is a mock function to get parent from devfile +func (d TestDevfileData) GetParent() *v1.Parent { + return &v1.Parent{} +} + +// SetParent is a mock function to set parent of the test devfile +func (d TestDevfileData) SetParent(parent *v1.Parent) {} + // GetEvents is a mock function to get events from devfile func (d TestDevfileData) GetEvents() v1.Events { return d.Events } -// GetParent is a mock function to get parent from devfile -func (d TestDevfileData) GetParent() *v1.Parent { - return &v1.Parent{} +// AddEvents is a mock function to add events to the test devfile +func (d TestDevfileData) AddEvents(events v1.Events) error { return nil } + +// UpdateEvents is a mock function to update the events of the test devfile +func (d TestDevfileData) UpdateEvents(postStart, postStop, preStart, preStop []string) {} + +// GetComponents is a mock function to get the components from a devfile +func (d TestDevfileData) GetComponents() []v1.Component { + return d.Components } -// GetProjects is a mock function to get the components that have an alias from a devfile +// AddComponents is a mock function to add components to the test devfile +func (d TestDevfileData) AddComponents(components []v1.Component) error { return nil } + +// UpdateComponent is a mock function to update the component of the test devfile +func (d TestDevfileData) UpdateComponent(component v1.Component) {} + +// GetProjects is a mock function to get the projects from a test devfile func (d TestDevfileData) GetProjects() []v1.Project { projectName := [...]string{"test-project", "anotherproject"} - clonePath := [...]string{"/test-project", "/anotherproject"} + clonePath := [...]string{"test-project/", "anotherproject/"} sourceLocation := [...]string{"https://github.com/someproject/test-project.git", "https://github.com/another/project.git"} project1 := v1.Project{ @@ -73,6 +98,25 @@ func (d TestDevfileData) GetProjects() []v1.Project { } +// AddProjects is a mock function to add projects to the test devfile +func (d TestDevfileData) AddProjects(projects []v1.Project) error { return nil } + +// UpdateProject is a mock function to update a project for the test devfile +func (d TestDevfileData) UpdateProject(project v1.Project) {} + +// GetStarterProjects is a mock function to get the starter projects from a test devfile +func (d TestDevfileData) GetStarterProjects() []v1.StarterProject { + return []v1.StarterProject{} +} + +// AddStarterProjects is a mock func to add the starter projects to the test devfile +func (d TestDevfileData) AddStarterProjects(projects []v1.StarterProject) error { + return nil +} + +// UpdateStarterProject is a mock func to update the starter project for a test devfile +func (d TestDevfileData) UpdateStarterProject(project v1.StarterProject) {} + // GetCommands is a mock function to get the commands from a devfile func (d TestDevfileData) GetCommands() map[string]v1.Command { @@ -81,7 +125,6 @@ func (d TestDevfileData) GetCommands() map[string]v1.Command { for _, command := range d.Commands { // we convert devfile command id to lowercase so that we can handle // cases efficiently without being error prone - // we also convert the odo push commands from build-command and run-command flags command.Id = strings.ToLower(command.Id) commands[command.Id] = command } @@ -89,35 +132,65 @@ func (d TestDevfileData) GetCommands() map[string]v1.Command { return commands } -// Validate is a mock validation that always validates without error -func (d TestDevfileData) Validate() error { +// AddCommands is a mock func that adds commands to the test devfile +func (d *TestDevfileData) AddCommands(commands ...v1.Command) error { + commandsMap := d.GetCommands() + + for _, command := range commands { + id := command.Id + if _, ok := commandsMap[id]; !ok { + d.Commands = append(d.Commands, command) + } else { + return fmt.Errorf("command %s already exist in the devfile", id) + } + } return nil } -// SetMetadata sets metadata for devfile -func (d TestDevfileData) SetMetadata(name, version string) {} - -func (d TestDevfileData) AddComponents(components []v1.Component) error { return nil } - -func (d TestDevfileData) UpdateComponent(component v1.Component) {} - -func (d TestDevfileData) AddCommands(commands []v1.Command) error { return nil } - +// UpdateCommand is a mock func to update the command in a test devfile func (d TestDevfileData) UpdateCommand(command v1.Command) {} -func (d TestDevfileData) SetEvents(events v1.Events) {} +// AddVolume is a mock func that adds volume to the test devfile +func (d TestDevfileData) AddVolume(volumeComponent v1.Component, path string) error { + return nil +} -func (d TestDevfileData) AddProjects(projects []v1.Project) error { return nil } +// DeleteVolume is a mock func that deletes volume from the test devfile +func (d TestDevfileData) DeleteVolume(name string) error { return nil } -func (d TestDevfileData) UpdateProject(project v1.Project) {} +// GetVolumeMountPath is a mock func that gets the volume mount path of a container +func (d TestDevfileData) GetVolumeMountPath(name string) (string, error) { + return "", nil +} -func (d TestDevfileData) AddEvents(events v1.Events) error { return nil } +// GetDevfileContainerComponents gets the container components from the test devfile +func (d TestDevfileData) GetDevfileContainerComponents() []v1.Component { + var components []v1.Component + for _, comp := range d.GetComponents() { + if comp.Container != nil { + components = append(components, comp) + } + } + return components +} -func (d TestDevfileData) UpdateEvents(postStart, postStop, preStart, preStop []string) {} +// GetDevfileVolumeComponents gets the volume components from the test devfile +func (d TestDevfileData) GetDevfileVolumeComponents() []v1.Component { + var components []v1.Component + for _, comp := range d.GetComponents() { + if comp.Volume != nil { + components = append(components, comp) + } + } + return components +} -func (d TestDevfileData) SetParent(parent *v1.Parent) {} +// Validate is a mock validation that always validates without error +func (d TestDevfileData) Validate() error { + return nil +} -// GetFakeContainerComponent returns a fake container component for testing +// GetFakeContainerComponent returns a fake container component for testing. func GetFakeContainerComponent(name string) v1.Component { image := "docker.io/maven:latest" memoryLimit := "128Mi" diff --git a/pkg/testingutil/resources.go b/pkg/testingutil/resources.go new file mode 100644 index 00000000..e6495a71 --- /dev/null +++ b/pkg/testingutil/resources.go @@ -0,0 +1,37 @@ +package testingutil + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +// FakeResourceRequirements creates a fake resource requirements from cpu and memory +func FakeResourceRequirements(cpu, memory string) (corev1.ResourceRequirements, error) { + var resReq corev1.ResourceRequirements + + limits := make(corev1.ResourceList) + var err error + limits[corev1.ResourceCPU], err = resource.ParseQuantity(cpu) + if err != nil { + return resReq, err + } + limits[corev1.ResourceMemory], err = resource.ParseQuantity(memory) + if err != nil { + return resReq, err + } + resReq.Limits = limits + + requests := make(corev1.ResourceList) + requests[corev1.ResourceCPU], err = resource.ParseQuantity(cpu) + if err != nil { + return resReq, err + } + requests[corev1.ResourceMemory], err = resource.ParseQuantity(memory) + if err != nil { + return resReq, err + } + + resReq.Requests = requests + + return resReq, nil +}