diff --git a/go.mod b/go.mod index 12128db7402..5bc439ca0e7 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/crossplane/crossplane-runtime v0.6.0 github.com/crossplane/crossplane-tools v0.0.0-20200219001116-bb8b2ce46330 github.com/docker/distribution v2.7.1+incompatible + github.com/evanphx/json-patch v4.5.0+incompatible github.com/ghodss/yaml v1.0.0 github.com/google/go-cmp v0.3.1 github.com/onsi/gomega v1.7.0 diff --git a/pkg/oam/trait/doc.go b/pkg/oam/trait/doc.go new file mode 100644 index 00000000000..7f431fe4bb9 --- /dev/null +++ b/pkg/oam/trait/doc.go @@ -0,0 +1,17 @@ +/* +Copyright 2019 The Crossplane Authors. + +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 trait diff --git a/pkg/oam/trait/manualscaler/doc.go b/pkg/oam/trait/manualscaler/doc.go new file mode 100644 index 00000000000..a9c6e893a3a --- /dev/null +++ b/pkg/oam/trait/manualscaler/doc.go @@ -0,0 +1,17 @@ +/* +Copyright 2019 The Crossplane Authors. + +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 manualscaler diff --git a/pkg/oam/trait/manualscaler/manualscaler.go b/pkg/oam/trait/manualscaler/manualscaler.go new file mode 100644 index 00000000000..95fb2a8c586 --- /dev/null +++ b/pkg/oam/trait/manualscaler/manualscaler.go @@ -0,0 +1,50 @@ +/* +Copyright 2020 The Crossplane Authors. + +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 manualscaler + +import ( + "context" + + "github.com/pkg/errors" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/crossplane/crossplane-runtime/pkg/resource" + + oamv1alpha2 "github.com/crossplane/crossplane/apis/oam/v1alpha2" +) + +const ( + errNotDeployment = "object to be modified is not a deployment" + errNotManualScalerTrait = "trait is not a manual scaler" +) + +// DeploymentModifier modifies the replica count of a Deployment. +func DeploymentModifier(ctx context.Context, obj runtime.Object, t resource.Trait) error { + d, ok := obj.(*appsv1.Deployment) + if !ok { + return errors.New(errNotDeployment) + } + + ms, ok := t.(*oamv1alpha2.ManualScalerTrait) + if !ok { + return errors.New(errNotManualScalerTrait) + } + d.Spec.Replicas = &ms.Spec.ReplicaCount + + return nil +} diff --git a/pkg/oam/trait/manualscaler/manualscaler_test.go b/pkg/oam/trait/manualscaler/manualscaler_test.go new file mode 100644 index 00000000000..b1fa63c3b10 --- /dev/null +++ b/pkg/oam/trait/manualscaler/manualscaler_test.go @@ -0,0 +1,99 @@ +/* +Copyright 2020 The Crossplane Authors. + +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 manualscaler + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/pkg/errors" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/crossplane/crossplane-runtime/pkg/reconciler/oam/trait" + "github.com/crossplane/crossplane-runtime/pkg/resource" + "github.com/crossplane/crossplane-runtime/pkg/resource/fake" + "github.com/crossplane/crossplane-runtime/pkg/test" + + oamv1alpha2 "github.com/crossplane/crossplane/apis/oam/v1alpha2" +) + +var ( + startingReplicas int32 = 2 +) + +var _ trait.Modifier = trait.ModifyFn(DeploymentModifier) + +func TestDeploymentModifier(t *testing.T) { + type args struct { + o runtime.Object + t resource.Trait + } + + type want struct { + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "ErrorObjectNotDeployment": { + reason: "Object passed to modifier that is not a Deployment should return error.", + args: args{ + o: &appsv1.DaemonSet{}, + }, + want: want{err: errors.New(errNotDeployment)}, + }, + "ErrorTraitNotManualScaler": { + reason: "Trait passed to modifier that is not a ManualScalerTrait should return error.", + args: args{ + o: &appsv1.Deployment{}, + t: &fake.Trait{}, + }, + want: want{err: errors.New(errNotManualScalerTrait)}, + }, + "Success": { + reason: "A Deployment should have its replicas field changed on successful modification.", + args: args{ + o: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: &startingReplicas, + }, + }, + t: &oamv1alpha2.ManualScalerTrait{ + Spec: oamv1alpha2.ManualScalerTraitSpec{ + ReplicaCount: 3, + }, + }, + }, + want: want{}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + err := DeploymentModifier(context.Background(), tc.args.o, tc.args.t) + + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\nReason: %s\nDeploymentModifier(...): -want error, +got error:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/pkg/oam/trait/modify.go b/pkg/oam/trait/modify.go new file mode 100644 index 00000000000..f9cf63a8a81 --- /dev/null +++ b/pkg/oam/trait/modify.go @@ -0,0 +1,75 @@ +/* +Copyright 2020 The Crossplane Authors. + +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 trait + +import ( + "context" + "encoding/json" + "reflect" + + "github.com/pkg/errors" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/crossplane/crossplane-runtime/pkg/reconciler/oam/trait" + "github.com/crossplane/crossplane-runtime/pkg/resource" + + workloadv1alpha1 "github.com/crossplane/crossplane/apis/workload/v1alpha1" +) + +const ( + errNotKubeApp = "object passed to KubernetesApplication accessor is not KubernetesApplication" + errNoDeploymentForTrait = "no deployment found for trait in KubernetesApplication" +) + +var ( + deploymentKind = reflect.TypeOf(appsv1.Deployment{}).Name() +) + +// DeploymentFromKubeAppAccessor finds deployments in a KubernetesApplication +// and applies the supplied modifier function to them. +func DeploymentFromKubeAppAccessor(ctx context.Context, obj runtime.Object, t resource.Trait, m trait.ModifyFn) error { + a, ok := obj.(*workloadv1alpha1.KubernetesApplication) + if !ok { + return errors.New(errNotKubeApp) + } + + for i, r := range a.Spec.ResourceTemplates { + template := &unstructured.Unstructured{} + if err := json.Unmarshal(r.Spec.Template.Raw, template); err != nil { + return err + } + if template.GroupVersionKind().Kind == deploymentKind { + d := &appsv1.Deployment{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(template.UnstructuredContent(), d); err != nil { + return err + } + if err := m(ctx, d, t); err != nil { + return err + } + deployment, err := json.Marshal(d) + if err != nil { + return err + } + a.Spec.ResourceTemplates[i].Spec.Template = runtime.RawExtension{Raw: deployment} + return nil + } + } + + return errors.New(errNoDeploymentForTrait) +} diff --git a/pkg/oam/trait/modify_test.go b/pkg/oam/trait/modify_test.go new file mode 100644 index 00000000000..84e30ef2b01 --- /dev/null +++ b/pkg/oam/trait/modify_test.go @@ -0,0 +1,117 @@ +/* +Copyright 2020 The Crossplane Authors. + +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 trait + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + runtimev1alpha1 "github.com/crossplane/crossplane-runtime/apis/core/v1alpha1" + "github.com/crossplane/crossplane-runtime/pkg/reconciler/oam/trait" + "github.com/crossplane/crossplane-runtime/pkg/resource" + "github.com/crossplane/crossplane-runtime/pkg/resource/fake" + "github.com/crossplane/crossplane-runtime/pkg/test" + + workloadv1alpha1 "github.com/crossplane/crossplane/apis/workload/v1alpha1" +) + +var ( + workloadName = "test-workload" +) + +var _ trait.ModifyAccessor = DeploymentFromKubeAppAccessor + +func TestDeploymentFromKubeAppAccessor(t *testing.T) { + type args struct { + o runtime.Object + t resource.Trait + m trait.ModifyFn + } + + type want struct { + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "ErrorObjectIsNotKubeApp": { + reason: "Object passed to accessor that is not a KubernetesApplication should return error.", + args: args{ + o: &workloadv1alpha1.KubernetesApplicationResource{}, + }, + want: want{err: errors.New(errNotKubeApp)}, + }, + "ErrorNoMatchingDeployment": { + reason: "Object passed to accessor that is not a KubernetesApplication should return error.", + args: args{ + o: &workloadv1alpha1.KubernetesApplication{}, + t: &fake.Trait{}, + }, + want: want{err: errors.New(errNoDeploymentForTrait)}, + }, + "SuccessfulNoopModifier": { + reason: "KubernetesApplication has matching Deployment and is modified successfully.", + args: args{ + o: &workloadv1alpha1.KubernetesApplication{ + ObjectMeta: metav1.ObjectMeta{}, + Spec: workloadv1alpha1.KubernetesApplicationSpec{ + ResourceTemplates: []workloadv1alpha1.KubernetesApplicationResourceTemplate{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: workloadName, + }, + Spec: workloadv1alpha1.KubernetesApplicationResourceSpec{ + Template: runtime.RawExtension{Raw: []byte(`{ + "kind":"Deployment", + "apiVersion":"apps/v1" + }`)}, + }, + }, + }, + }, + }, + t: &fake.Trait{ + WorkloadReferencer: fake.WorkloadReferencer{ + Ref: runtimev1alpha1.TypedReference{ + Name: workloadName, + }, + }, + }, + m: trait.NoopModifier, + }, + want: want{}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + err := DeploymentFromKubeAppAccessor(context.Background(), tc.args.o, tc.args.t, tc.args.m) + + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\nReason: %s\nDeploymentFromKubeAppAccessor(...): -want error, +got error:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/pkg/oam/workload/containerized/containerized.go b/pkg/oam/workload/containerized/containerized.go new file mode 100644 index 00000000000..76a07a01fd2 --- /dev/null +++ b/pkg/oam/workload/containerized/containerized.go @@ -0,0 +1,256 @@ +/* +Copyright 2020 The Crossplane Authors. + +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 containerized + +import ( + "context" + "reflect" + + "github.com/pkg/errors" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/crossplane/crossplane-runtime/pkg/resource" + + oamv1alpha2 "github.com/crossplane/crossplane/apis/oam/v1alpha2" +) + +// Reconcile error strings. +const ( + errNotContainerizedWorkload = "object is not a containerized workload" +) + +const labelKey = "containerizedworkload.oam.crossplane.io" + +var ( + deploymentKind = reflect.TypeOf(appsv1.Deployment{}).Name() + deploymentAPIVersion = appsv1.SchemeGroupVersion.String() +) + +// Translator translates a ContainerizedWorkload into a Deployment. +// nolint:gocyclo +func Translator(ctx context.Context, w resource.Workload) ([]resource.Object, error) { + cw, ok := w.(*oamv1alpha2.ContainerizedWorkload) + if !ok { + return nil, errors.New(errNotContainerizedWorkload) + } + + d := &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{ + Kind: deploymentKind, + APIVersion: deploymentAPIVersion, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: cw.GetName(), + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + labelKey: string(cw.GetUID()), + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + labelKey: string(cw.GetUID()), + }, + }, + }, + }, + } + if cw.Spec.OperatingSystem != nil { + if d.Spec.Template.Spec.NodeSelector == nil { + d.Spec.Template.Spec.NodeSelector = map[string]string{} + } + d.Spec.Template.Spec.NodeSelector["beta.kubernetes.io/os"] = string(*cw.Spec.OperatingSystem) + } + + if cw.Spec.CPUArchitecture != nil { + if d.Spec.Template.Spec.NodeSelector == nil { + d.Spec.Template.Spec.NodeSelector = map[string]string{} + } + d.Spec.Template.Spec.NodeSelector["kubernetes.io/arch"] = string(*cw.Spec.CPUArchitecture) + } + + for _, container := range cw.Spec.Containers { + if container.ImagePullSecret != nil { + d.Spec.Template.Spec.ImagePullSecrets = append(d.Spec.Template.Spec.ImagePullSecrets, corev1.LocalObjectReference{ + Name: *container.ImagePullSecret, + }) + } + kubernetesContainer := corev1.Container{ + Name: container.Name, + Image: container.Image, + Command: container.Command, + Args: container.Arguments, + } + + if container.Resources != nil { + kubernetesContainer.Resources = corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: container.Resources.CPU.Required, + corev1.ResourceMemory: container.Resources.Memory.Required, + }, + } + for _, v := range container.Resources.Volumes { + mount := corev1.VolumeMount{ + Name: v.Name, + MountPath: v.MouthPath, + } + if v.AccessMode != nil && *v.AccessMode == oamv1alpha2.VolumeAccessModeRO { + mount.ReadOnly = true + } + kubernetesContainer.VolumeMounts = append(kubernetesContainer.VolumeMounts, mount) + + } + } + + for _, p := range container.Ports { + port := corev1.ContainerPort{ + Name: p.Name, + ContainerPort: p.Port, + } + if p.Protocol != nil { + port.Protocol = corev1.Protocol(*p.Protocol) + } + kubernetesContainer.Ports = append(kubernetesContainer.Ports, port) + } + + for _, e := range container.Environment { + if e.Value != nil { + kubernetesContainer.Env = append(kubernetesContainer.Env, corev1.EnvVar{ + Name: e.Name, + Value: *e.Value, + }) + continue + } + if e.FromSecret != nil { + kubernetesContainer.Env = append(kubernetesContainer.Env, corev1.EnvVar{ + Name: e.Name, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + Key: e.FromSecret.Key, + LocalObjectReference: corev1.LocalObjectReference{ + Name: e.FromSecret.Name, + }, + }, + }, + }) + } + } + + if container.LivenessProbe != nil { + kubernetesContainer.LivenessProbe = &corev1.Probe{} + if container.LivenessProbe.InitialDelaySeconds != nil { + kubernetesContainer.LivenessProbe.InitialDelaySeconds = *container.LivenessProbe.InitialDelaySeconds + } + if container.LivenessProbe.TimeoutSeconds != nil { + kubernetesContainer.LivenessProbe.TimeoutSeconds = *container.LivenessProbe.TimeoutSeconds + } + if container.LivenessProbe.PeriodSeconds != nil { + kubernetesContainer.LivenessProbe.PeriodSeconds = *container.LivenessProbe.PeriodSeconds + } + if container.LivenessProbe.SuccessThreshold != nil { + kubernetesContainer.LivenessProbe.SuccessThreshold = *container.LivenessProbe.SuccessThreshold + } + if container.LivenessProbe.FailureThreshold != nil { + kubernetesContainer.LivenessProbe.FailureThreshold = *container.LivenessProbe.FailureThreshold + } + + // NOTE(hasheddan): Kubernetes specifies that only one type of + // handler should be provided. OAM does not impose that same + // restriction. We optimistically check all and set whatever is + // provided. + if container.LivenessProbe.HTTPGet != nil { + kubernetesContainer.LivenessProbe.Handler.HTTPGet = &corev1.HTTPGetAction{ + Path: container.LivenessProbe.HTTPGet.Path, + Port: intstr.IntOrString{IntVal: container.LivenessProbe.HTTPGet.Port}, + } + + for _, h := range container.LivenessProbe.HTTPGet.HTTPHeaders { + kubernetesContainer.LivenessProbe.Handler.HTTPGet.HTTPHeaders = append(kubernetesContainer.LivenessProbe.Handler.HTTPGet.HTTPHeaders, corev1.HTTPHeader{ + Name: h.Name, + Value: h.Value, + }) + } + } + if container.LivenessProbe.Exec != nil { + kubernetesContainer.LivenessProbe.Exec = &corev1.ExecAction{ + Command: container.LivenessProbe.Exec.Command, + } + } + if container.LivenessProbe.TCPSocket != nil { + kubernetesContainer.LivenessProbe.TCPSocket = &corev1.TCPSocketAction{ + Port: intstr.IntOrString{IntVal: container.LivenessProbe.TCPSocket.Port}, + } + } + } + + if container.ReadinessProbe != nil { + kubernetesContainer.ReadinessProbe = &corev1.Probe{} + if container.ReadinessProbe.InitialDelaySeconds != nil { + kubernetesContainer.ReadinessProbe.InitialDelaySeconds = *container.ReadinessProbe.InitialDelaySeconds + } + if container.ReadinessProbe.TimeoutSeconds != nil { + kubernetesContainer.ReadinessProbe.TimeoutSeconds = *container.ReadinessProbe.TimeoutSeconds + } + if container.ReadinessProbe.PeriodSeconds != nil { + kubernetesContainer.ReadinessProbe.PeriodSeconds = *container.ReadinessProbe.PeriodSeconds + } + if container.ReadinessProbe.SuccessThreshold != nil { + kubernetesContainer.ReadinessProbe.SuccessThreshold = *container.ReadinessProbe.SuccessThreshold + } + if container.ReadinessProbe.FailureThreshold != nil { + kubernetesContainer.ReadinessProbe.FailureThreshold = *container.ReadinessProbe.FailureThreshold + } + + // NOTE(hasheddan): Kubernetes specifies that only one type of + // handler should be provided. OAM does not impose that same + // restriction. We optimistically check all and set whatever is + // provided. + if container.ReadinessProbe.HTTPGet != nil { + kubernetesContainer.ReadinessProbe.Handler.HTTPGet = &corev1.HTTPGetAction{ + Path: container.ReadinessProbe.HTTPGet.Path, + Port: intstr.IntOrString{IntVal: container.ReadinessProbe.HTTPGet.Port}, + } + + for _, h := range container.ReadinessProbe.HTTPGet.HTTPHeaders { + kubernetesContainer.ReadinessProbe.Handler.HTTPGet.HTTPHeaders = append(kubernetesContainer.ReadinessProbe.Handler.HTTPGet.HTTPHeaders, corev1.HTTPHeader{ + Name: h.Name, + Value: h.Value, + }) + } + } + if container.ReadinessProbe.Exec != nil { + kubernetesContainer.ReadinessProbe.Exec = &corev1.ExecAction{ + Command: container.ReadinessProbe.Exec.Command, + } + } + if container.ReadinessProbe.TCPSocket != nil { + kubernetesContainer.ReadinessProbe.TCPSocket = &corev1.TCPSocketAction{ + Port: intstr.IntOrString{IntVal: container.ReadinessProbe.TCPSocket.Port}, + } + } + } + + d.Spec.Template.Spec.Containers = append(d.Spec.Template.Spec.Containers, kubernetesContainer) + } + + return []resource.Object{d}, nil +} diff --git a/pkg/oam/workload/containerized/containerized_test.go b/pkg/oam/workload/containerized/containerized_test.go new file mode 100644 index 00000000000..04dc1962a4e --- /dev/null +++ b/pkg/oam/workload/containerized/containerized_test.go @@ -0,0 +1,280 @@ +/* +Copyright 2020 The Crossplane Authors. + +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 containerized + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/pkg/errors" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/crossplane/crossplane-runtime/pkg/reconciler/oam/workload" + "github.com/crossplane/crossplane-runtime/pkg/resource" + "github.com/crossplane/crossplane-runtime/pkg/resource/fake" + "github.com/crossplane/crossplane-runtime/pkg/test" + + oamv1alpha2 "github.com/crossplane/crossplane/apis/oam/v1alpha2" +) + +var ( + cwName = "test-name" + cwNamespace = "test-namespace" + cwUID = "a-very-unique-identifier" +) + +type deploymentModifier func(*appsv1.Deployment) + +func dmWithOS(os string) deploymentModifier { + return func(d *appsv1.Deployment) { + if d.Spec.Template.Spec.NodeSelector == nil { + d.Spec.Template.Spec.NodeSelector = map[string]string{} + } + d.Spec.Template.Spec.NodeSelector["beta.kubernetes.io/os"] = os + } +} + +func dmWithContainer(c corev1.Container) deploymentModifier { + return func(d *appsv1.Deployment) { + d.Spec.Template.Spec.Containers = append(d.Spec.Template.Spec.Containers, c) + } +} + +func deployment(mod ...deploymentModifier) *appsv1.Deployment { + d := &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{ + Kind: deploymentKind, + APIVersion: deploymentAPIVersion, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: cwName, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + labelKey: cwUID, + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + labelKey: cwUID, + }, + }, + }, + }, + } + + for _, m := range mod { + m(d) + } + + return d +} + +type cwModifier func(*oamv1alpha2.ContainerizedWorkload) + +func cwWithOS(os string) cwModifier { + return func(cw *oamv1alpha2.ContainerizedWorkload) { + oamOS := oamv1alpha2.OperatingSystem(os) + cw.Spec.OperatingSystem = &oamOS + } +} + +func cwWithContainer(c oamv1alpha2.Container) cwModifier { + return func(cw *oamv1alpha2.ContainerizedWorkload) { + cw.Spec.Containers = append(cw.Spec.Containers, c) + } +} + +func containerizedWorkload(mod ...cwModifier) *oamv1alpha2.ContainerizedWorkload { + cw := &oamv1alpha2.ContainerizedWorkload{ + ObjectMeta: metav1.ObjectMeta{ + Name: cwName, + Namespace: cwNamespace, + UID: types.UID(cwUID), + }, + } + + for _, m := range mod { + m(cw) + } + + return cw +} + +var _ workload.Translator = workload.TranslateFn(Translator) + +func TestTranslator(t *testing.T) { + + envVarSecretVal := "nicesecretvalue" + + type args struct { + w resource.Workload + } + + type want struct { + result []resource.Object + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "ErrorWorkloadNotContainerizedWorkload": { + reason: "Workload passed to translator that is not ContainerizedWorkload should return error.", + args: args{ + w: &fake.Workload{}, + }, + want: want{err: errors.New(errNotContainerizedWorkload)}, + }, + "SuccessfulEmpty": { + reason: "A ContainerizedWorkload should be successfully translated into a deployment.", + args: args{ + w: containerizedWorkload(), + }, + want: want{result: []resource.Object{deployment()}}, + }, + "SuccessfulOS": { + reason: "A ContainerizedWorkload should be successfully translateddinto a deployment.", + args: args{ + w: containerizedWorkload(cwWithOS("test")), + }, + want: want{result: []resource.Object{deployment(dmWithOS("test"))}}, + }, + "SuccessfulContainers": { + reason: "A ContainerizedWorkload should be successfully translated into a deployment.", + args: args{ + w: containerizedWorkload(cwWithContainer(oamv1alpha2.Container{ + Name: "cool-container", + Image: "cool/image:latest", + Command: []string{"run"}, + Arguments: []string{"--coolflag"}, + Ports: []oamv1alpha2.ContainerPort{ + { + Name: "cool-port", + Port: 8080, + }, + }, + Resources: &oamv1alpha2.ContainerResources{ + Volumes: []oamv1alpha2.VolumeResource{ + { + Name: "cool-volume", + MouthPath: "/my/cool/path", + }, + }, + }, + Environment: []oamv1alpha2.ContainerEnvVar{ + { + Name: "COOL_SECRET", + FromSecret: &oamv1alpha2.SecretKeySelector{ + Name: "cool-secret", + Key: "secretdata", + }, + }, + { + Name: "NICE_SECRET", + Value: &envVarSecretVal, + }, + // If both Value and FromSecret are defined, we use Value + { + Name: "USE_VAL_SECRET", + Value: &envVarSecretVal, + FromSecret: &oamv1alpha2.SecretKeySelector{ + Name: "cool-secret", + Key: "secretdata", + }, + }, + // If neither Value or FromSecret is define, we skip + { + Name: "USE_VAL_SECRET", + }, + }, + })), + }, + want: want{result: []resource.Object{deployment(dmWithContainer(corev1.Container{ + Name: "cool-container", + Image: "cool/image:latest", + Command: []string{"run"}, + Args: []string{"--coolflag"}, + Ports: []corev1.ContainerPort{ + { + Name: "cool-port", + ContainerPort: 8080, + }, + }, + // CPU and Memory get initialized because we set them if any + // part of OAM Container.Resources is present. They are not + // pointer values, so we cannot tell if they were omitted or + // explicitly set to zero-value. + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + "cpu": {}, + "memory": {}, + }, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "cool-volume", + MountPath: "/my/cool/path", + }, + }, + Env: []corev1.EnvVar{ + { + Name: "COOL_SECRET", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + Key: "secretdata", + LocalObjectReference: corev1.LocalObjectReference{ + Name: "cool-secret", + }, + }, + }, + }, + { + Name: "NICE_SECRET", + Value: envVarSecretVal, + }, + { + Name: "USE_VAL_SECRET", + Value: envVarSecretVal, + }, + }, + }))}}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + r, err := Translator(context.Background(), tc.args.w) + + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\nReason: %s\ncontainerizedWorkloadTranslator(...): -want error, +got error:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.result, r); diff != "" { + t.Errorf("\nReason: %s\ncontainerizedWorkloadTranslator(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/pkg/oam/workload/containerized/doc.go b/pkg/oam/workload/containerized/doc.go new file mode 100644 index 00000000000..13b0eed1b3d --- /dev/null +++ b/pkg/oam/workload/containerized/doc.go @@ -0,0 +1,17 @@ +/* +Copyright 2019 The Crossplane Authors. + +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 containerized diff --git a/pkg/oam/workload/doc.go b/pkg/oam/workload/doc.go new file mode 100644 index 00000000000..9fccc78a30f --- /dev/null +++ b/pkg/oam/workload/doc.go @@ -0,0 +1,17 @@ +/* +Copyright 2019 The Crossplane Authors. + +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 workload diff --git a/pkg/oam/workload/translate.go b/pkg/oam/workload/translate.go new file mode 100644 index 00000000000..34d9a88ac6c --- /dev/null +++ b/pkg/oam/workload/translate.go @@ -0,0 +1,144 @@ +/* +Copyright 2020 The Crossplane Authors. + +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 workload + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "strings" + + "github.com/pkg/errors" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/crossplane/crossplane-runtime/pkg/resource" + + workloadv1alpha1 "github.com/crossplane/crossplane/apis/workload/v1alpha1" +) + +const ( + errWrapInKubeApp = "unable to wrap objects in KubernetesApplication" +) + +var ( + serviceKind = reflect.TypeOf(corev1.Service{}).Name() + serviceAPIVersion = corev1.SchemeGroupVersion.String() +) + +// LabelKey is the label applied to translated workload objects. +const LabelKey = "workload.oam.crossplane.io" + +// KubeAppWrapper wraps a set of translated objects in a KubernetesApplication. +func KubeAppWrapper(ctx context.Context, w resource.Workload, objs []resource.Object) ([]resource.Object, error) { + if objs == nil { + return nil, nil + } + + app := &workloadv1alpha1.KubernetesApplication{} + + for _, o := range objs { + b, err := json.Marshal(o) + if err != nil { + return nil, errors.Wrap(err, errWrapInKubeApp) + } + + kart := workloadv1alpha1.KubernetesApplicationResourceTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-%s", o.GetName(), strings.ToLower(o.GetObjectKind().GroupVersionKind().Kind)), + Labels: map[string]string{ + LabelKey: string(w.GetUID()), + }, + }, + Spec: workloadv1alpha1.KubernetesApplicationResourceSpec{ + Template: runtime.RawExtension{Raw: b}, + }, + } + + app.Spec.ResourceTemplates = append(app.Spec.ResourceTemplates, kart) + } + + app.SetName(w.GetName()) + + app.Spec.ResourceSelector = &metav1.LabelSelector{ + MatchLabels: map[string]string{ + LabelKey: string(w.GetUID()), + }, + } + + return []resource.Object{app}, nil +} + +// ServiceInjector adds a Service object for the first Port on the first +// Container for the first Deployment observed in a workload translation. +func ServiceInjector(ctx context.Context, w resource.Workload, objs []resource.Object) ([]resource.Object, error) { + if objs == nil { + return nil, nil + } + + for _, o := range objs { + d, ok := o.(*appsv1.Deployment) + if !ok { + continue + } + + // We don't add a Service if there are no containers for the Deployment. + // This should never happen in practice. + if len(d.Spec.Template.Spec.Containers) < 1 { + continue + } + + s := &corev1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: serviceKind, + APIVersion: serviceAPIVersion, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: d.GetName(), + Labels: map[string]string{ + LabelKey: string(w.GetUID()), + }, + }, + Spec: corev1.ServiceSpec{ + Selector: d.Spec.Selector.MatchLabels, + Ports: []corev1.ServicePort{}, + Type: corev1.ServiceTypeLoadBalancer, + }, + } + + // We only add a single Service for the Deployment, even if multiple + // ports or no ports are defined on the first container. This is to + // exclude the need for implementing garbage collection in the + // short-term in the case that ports are modified after creation. + if len(d.Spec.Template.Spec.Containers[0].Ports) > 0 { + s.Spec.Ports = []corev1.ServicePort{ + { + Name: d.GetName(), + Port: d.Spec.Template.Spec.Containers[0].Ports[0].ContainerPort, + TargetPort: intstr.FromInt(int(d.Spec.Template.Spec.Containers[0].Ports[0].ContainerPort)), + }, + } + } + objs = append(objs, s) + break + } + return objs, nil +} diff --git a/pkg/oam/workload/translate_test.go b/pkg/oam/workload/translate_test.go new file mode 100644 index 00000000000..9014c9bc0ed --- /dev/null +++ b/pkg/oam/workload/translate_test.go @@ -0,0 +1,345 @@ +/* +Copyright 2020 The Crossplane Authors. + +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 workload + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/crossplane/crossplane-runtime/pkg/reconciler/oam/workload" + "github.com/crossplane/crossplane-runtime/pkg/resource" + "github.com/crossplane/crossplane-runtime/pkg/resource/fake" + "github.com/crossplane/crossplane-runtime/pkg/test" + + workloadv1alpha1 "github.com/crossplane/crossplane/apis/workload/v1alpha1" +) + +var ( + workloadName = "test-workload" + workloadNamespace = "test-namespace" + workloadUID = "a-very-unique-identifier" + + containerName = "test-container" + portName = "test-port" +) + +var ( + deploymentKind = reflect.TypeOf(appsv1.Deployment{}).Name() + deploymentAPIVersion = appsv1.SchemeGroupVersion.String() +) + +type deploymentModifier func(*appsv1.Deployment) + +func dmWithContainerPorts(ports ...int32) deploymentModifier { + return func(d *appsv1.Deployment) { + p := []corev1.ContainerPort{} + for _, port := range ports { + p = append(p, corev1.ContainerPort{ + Name: portName, + ContainerPort: port, + }) + } + d.Spec.Template.Spec.Containers = append(d.Spec.Template.Spec.Containers, corev1.Container{ + Name: containerName, + Ports: p, + }) + } +} + +func deployment(mod ...deploymentModifier) *appsv1.Deployment { + d := &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{ + Kind: deploymentKind, + APIVersion: deploymentAPIVersion, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: workloadName, + CreationTimestamp: metav1.NewTime(time.Date(0, 0, 0, 0, 0, 0, 0, time.Local)), + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + LabelKey: workloadUID, + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + CreationTimestamp: metav1.NewTime(time.Date(0, 0, 0, 0, 0, 0, 0, time.Local)), + Labels: map[string]string{ + LabelKey: workloadUID, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{}, + }, + }, + }, + } + + for _, m := range mod { + m(d) + } + + return d +} + +type serviceModifier func(*corev1.Service) + +func sWithContainerPort(target int) serviceModifier { + return func(s *corev1.Service) { + s.Spec.Ports = append(s.Spec.Ports, corev1.ServicePort{ + Name: workloadName, + Port: int32(target), + TargetPort: intstr.FromInt(target), + }) + } +} + +func service(mod ...serviceModifier) *corev1.Service { + s := &corev1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: serviceKind, + APIVersion: serviceAPIVersion, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: workloadName, + Labels: map[string]string{ + LabelKey: workloadUID, + }, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{ + LabelKey: workloadUID, + }, + Type: corev1.ServiceTypeLoadBalancer, + }, + } + + for _, m := range mod { + m(s) + } + + return s +} + +var _ workload.TranslationWrapper = KubeAppWrapper + +func TestKubeAppWrapper(t *testing.T) { + deployBytes, _ := json.Marshal(deployment()) + type args struct { + w resource.Workload + o []resource.Object + } + + type want struct { + result []resource.Object + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "NilObject": { + reason: "Nil object should immediately return nil.", + args: args{ + w: &fake.Workload{}, + }, + want: want{}, + }, + "SuccessfulWrapDeployment": { + reason: "A Deployment should be able to be wrapped in a KubernetesApplication.", + args: args{ + w: &fake.Workload{ + ObjectMeta: metav1.ObjectMeta{ + Name: workloadName, + Namespace: workloadNamespace, + UID: types.UID(workloadUID), + }, + }, + o: []resource.Object{deployment()}, + }, + want: want{result: []resource.Object{&workloadv1alpha1.KubernetesApplication{ + ObjectMeta: metav1.ObjectMeta{ + Name: workloadName, + }, + Spec: workloadv1alpha1.KubernetesApplicationSpec{ + ResourceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + LabelKey: workloadUID, + }, + }, + ResourceTemplates: []workloadv1alpha1.KubernetesApplicationResourceTemplate{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-%s", workloadName, "deployment"), + Labels: map[string]string{LabelKey: workloadUID}, + }, + Spec: workloadv1alpha1.KubernetesApplicationResourceSpec{ + Template: runtime.RawExtension{Raw: deployBytes}, + }, + }, + }, + }, + }}, + }}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + r, err := KubeAppWrapper(context.Background(), tc.args.w, tc.args.o) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\nReason: %s\nKubeAppWrapper(...): -want error, +got error:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.result, r); diff != "" { + t.Errorf("\nReason: %s\nKubeAppWrapper(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +var _ workload.TranslationWrapper = ServiceInjector + +func TestServiceInjector(t *testing.T) { + type args struct { + w resource.Workload + o []resource.Object + } + + type want struct { + result []resource.Object + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "NilObject": { + reason: "Nil object should immediately return nil.", + args: args{ + w: &fake.Workload{}, + }, + want: want{}, + }, + "SuccessfulInjectService_1D_1C_1P": { + reason: "A Deployment with a port(s) should have a Service injected for first defined port.", + args: args{ + w: &fake.Workload{ + ObjectMeta: metav1.ObjectMeta{ + Name: workloadName, + Namespace: workloadNamespace, + UID: types.UID(workloadUID), + }, + }, + o: []resource.Object{deployment(dmWithContainerPorts(3000))}, + }, + want: want{result: []resource.Object{ + deployment(dmWithContainerPorts(3000)), + service(sWithContainerPort(3000)), + }}, + }, + "SuccessfulInjectService_1D_1C_2P": { + reason: "A Deployment with a port(s) should have a Service injected for first defined port on the first container.", + args: args{ + w: &fake.Workload{ + ObjectMeta: metav1.ObjectMeta{ + Name: workloadName, + Namespace: workloadNamespace, + UID: types.UID(workloadUID), + }, + }, + o: []resource.Object{deployment(dmWithContainerPorts(3000, 3001))}, + }, + want: want{result: []resource.Object{ + deployment(dmWithContainerPorts(3000, 3001)), + service(sWithContainerPort(3000)), + }}, + }, + "SuccessfulInjectService_2D_1C_1P": { + reason: "The first Deployment with a port(s) should have a Service injected for first defined port on the first container.", + args: args{ + w: &fake.Workload{ + ObjectMeta: metav1.ObjectMeta{ + Name: workloadName, + Namespace: workloadNamespace, + UID: types.UID(workloadUID), + }, + }, + o: []resource.Object{ + deployment(dmWithContainerPorts(4000)), + deployment(dmWithContainerPorts(3000)), + }, + }, + want: want{result: []resource.Object{ + deployment(dmWithContainerPorts(4000)), + deployment(dmWithContainerPorts(3000)), + service(sWithContainerPort(4000)), + }}, + }, + "SuccessfulInjectService_2D_2C_2P": { + reason: "The first Deployment with a port(s) should have a Service injected for first defined port on the first container.", + args: args{ + w: &fake.Workload{ + ObjectMeta: metav1.ObjectMeta{ + Name: workloadName, + Namespace: workloadNamespace, + UID: types.UID(workloadUID), + }, + }, + o: []resource.Object{ + deployment(dmWithContainerPorts(3000, 3001), dmWithContainerPorts(4000, 4001)), + deployment(dmWithContainerPorts(5000, 5001), dmWithContainerPorts(6000, 6001)), + }, + }, + want: want{result: []resource.Object{ + deployment(dmWithContainerPorts(3000, 3001), dmWithContainerPorts(4000, 4001)), + deployment(dmWithContainerPorts(5000, 5001), dmWithContainerPorts(6000, 6001)), + service(sWithContainerPort(3000)), + }}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + r, err := ServiceInjector(context.Background(), tc.args.w, tc.args.o) + + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\nReason: %s\nServiceInjector(...): -want error, +got error:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.result, r); diff != "" { + t.Errorf("\nReason: %s\nServiceInjector(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/pkg/workload/apply.go b/pkg/workload/apply.go new file mode 100644 index 00000000000..b31d1e138ac --- /dev/null +++ b/pkg/workload/apply.go @@ -0,0 +1,92 @@ +/* +Copyright 2020 The Crossplane Authors. + +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 workload + +import ( + "context" + "encoding/json" + + jsonpatch "github.com/evanphx/json-patch" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/pkg/resource" + + workloadv1alpha1 "github.com/crossplane/crossplane/apis/workload/v1alpha1" +) + +var ( + errNotKubeApp = "object is not a KubernetesApplication" + errMergeKubeAppTemplates = "cannot merge KubernetesApplicationResourceTemplates" +) + +type template struct { + gvk schema.GroupVersionKind + name string +} + +// KubeAppApplyOption ensures resource templates are merged instead of replaced +// before patch if they have the same name and GroupVersionKind. We must merge +// the current and desired templates prior to submitting a Patch to the API +// server because KubernetesApplicationResourceTemplates are stored as an array +// in the KubernetesApplication. This means that entire templates will be +// replaced when a single field is different, per +// https://tools.ietf.org/html/rfc7386. We instead patch each of the resource +// templates individually before passing along the entire KubernetesApplication +// to resource.Apply. +func KubeAppApplyOption() resource.ApplyOption { + return func(_ context.Context, current, desired runtime.Object) error { + c, ok := current.(*workloadv1alpha1.KubernetesApplication) + if !ok { + return errors.New(errNotKubeApp) + } + d, ok := desired.(*workloadv1alpha1.KubernetesApplication) + if !ok { + return errors.New(errNotKubeApp) + } + + index := make(map[template]int) + for i, t := range d.Spec.ResourceTemplates { + temp := &unstructured.Unstructured{} + if err := json.Unmarshal(t.Spec.Template.Raw, temp); err != nil { + return errors.Wrap(err, errMergeKubeAppTemplates) + } + index[template{gvk: temp.GroupVersionKind(), name: t.GetName()}] = i + } + + for _, t := range c.Spec.ResourceTemplates { + temp := &unstructured.Unstructured{} + if err := json.Unmarshal(t.Spec.Template.Raw, temp); err != nil { + return errors.Wrap(err, errMergeKubeAppTemplates) + } + i, ok := index[template{gvk: temp.GroupVersionKind(), name: t.GetName()}] + if !ok { + continue + } + + merged, err := jsonpatch.MergePatch(t.Spec.Template.Raw, d.Spec.ResourceTemplates[i].Spec.Template.Raw) + if err != nil { + return errors.Wrap(err, errMergeKubeAppTemplates) + } + d.Spec.ResourceTemplates[i].Spec.Template = runtime.RawExtension{Raw: merged} + } + + return nil + } +} diff --git a/pkg/workload/apply_test.go b/pkg/workload/apply_test.go new file mode 100644 index 00000000000..01047fdb031 --- /dev/null +++ b/pkg/workload/apply_test.go @@ -0,0 +1,237 @@ +/* +Copyright 2020 The Crossplane Authors. + +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 workload + +import ( + "context" + "encoding/json" + "reflect" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/pkg/errors" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/crossplane/crossplane-runtime/pkg/resource" + "github.com/crossplane/crossplane-runtime/pkg/test" + + workloadv1alpha1 "github.com/crossplane/crossplane/apis/workload/v1alpha1" +) + +var ( + workloadName = "test-workload" + workloadUID = "a-very-unique-identifier" + + replicas = int32(3) + containerName = "test-container" + portName = "test-port" +) + +var ( + deploymentKind = reflect.TypeOf(appsv1.Deployment{}).Name() + deploymentAPIVersion = appsv1.SchemeGroupVersion.String() +) + +type deploymentModifier func(*appsv1.Deployment) + +func dmWithContainerPorts(ports ...int32) deploymentModifier { + return func(d *appsv1.Deployment) { + p := []corev1.ContainerPort{} + for _, port := range ports { + p = append(p, corev1.ContainerPort{ + Name: portName, + ContainerPort: port, + }) + } + d.Spec.Template.Spec.Containers = append(d.Spec.Template.Spec.Containers, corev1.Container{ + Name: containerName, + Ports: p, + }) + } +} + +func dmWithReplicas(r *int32) deploymentModifier { + return func(d *appsv1.Deployment) { + d.Spec.Replicas = r + } +} + +func deployment(mod ...deploymentModifier) *appsv1.Deployment { + d := &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{ + Kind: deploymentKind, + APIVersion: deploymentAPIVersion, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: workloadName, + CreationTimestamp: metav1.NewTime(time.Date(0, 0, 0, 0, 0, 0, 0, time.Local)), + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "test-label": workloadUID, + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + CreationTimestamp: metav1.NewTime(time.Date(0, 0, 0, 0, 0, 0, 0, time.Local)), + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{}, + }, + }, + }, + } + + for _, m := range mod { + m(d) + } + + return d +} + +type kubeAppModifier func(*workloadv1alpha1.KubernetesApplication) + +func kaWithTemplate(name string, o runtime.Object) kubeAppModifier { + return func(a *workloadv1alpha1.KubernetesApplication) { + b, _ := json.Marshal(o) + a.Spec.ResourceTemplates = append(a.Spec.ResourceTemplates, workloadv1alpha1.KubernetesApplicationResourceTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + CreationTimestamp: metav1.NewTime(time.Date(0, 0, 0, 0, 0, 0, 0, time.Local)), + }, + Spec: workloadv1alpha1.KubernetesApplicationResourceSpec{ + Template: runtime.RawExtension{Raw: b}, + }, + }) + } +} + +func kubeApp(mod ...kubeAppModifier) *workloadv1alpha1.KubernetesApplication { + a := &workloadv1alpha1.KubernetesApplication{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cool-kapp", + }, + } + + for _, m := range mod { + m(a) + } + + return a +} + +var _ resource.ApplyOption = KubeAppApplyOption() + +func TestKubeAppApplyOption(t *testing.T) { + type args struct { + c runtime.Object + d runtime.Object + } + + type want struct { + o runtime.Object + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "NotAKubernetesApplication": { + reason: "An error should be returned if the object is not a KubernetesApplication", + args: args{ + c: &corev1.Namespace{}, + d: &corev1.Namespace{}, + }, + want: want{ + o: &corev1.Namespace{}, + err: errors.New(errNotKubeApp), + }, + }, + "PatchedNoOverwrite": { + reason: "If existing and desired have the same name and kind of a template, non-array fields in templates should not be overwritten in patch", + args: args{ + c: kubeApp(kaWithTemplate("cool-temp", deployment(dmWithReplicas(&replicas)))), + d: kubeApp(kaWithTemplate("cool-temp", deployment())), + }, + want: want{ + o: kubeApp(kaWithTemplate("cool-temp", deployment(dmWithReplicas(&replicas)))), + }, + }, + "PatchedRemoveResource": { + reason: "If existing and desired have different template names, the existing template should be overwritten by the desired", + args: args{ + c: kubeApp(kaWithTemplate("cool-temp", deployment()), kaWithTemplate("nice-temp", deployment())), + d: kubeApp(kaWithTemplate("cool-temp", deployment())), + }, + want: want{ + o: kubeApp(kaWithTemplate("cool-temp", deployment())), + }, + }, + "PatchedAddResource": { + reason: "If existing and desired have different template names, the existing template should be overwritten by the desired", + args: args{ + c: kubeApp(kaWithTemplate("cool-temp", deployment())), + d: kubeApp(kaWithTemplate("cool-temp", deployment()), kaWithTemplate("nice-temp", deployment())), + }, + want: want{ + o: kubeApp(kaWithTemplate("cool-temp", deployment()), kaWithTemplate("nice-temp", deployment())), + }, + }, + "PatchedOverwrite": { + reason: "If existing and desired have different template names, the existing template should be overwritten by the desired", + args: args{ + c: kubeApp(kaWithTemplate("nice-temp", deployment())), + d: kubeApp(kaWithTemplate("cool-temp", deployment())), + }, + want: want{ + o: kubeApp(kaWithTemplate("cool-temp", deployment())), + }, + }, + "PatchedPartialOverwrite": { + reason: "If existing and desired have the same name and kind of a template, array fields in templates should be overwritten in patch", + args: args{ + c: kubeApp(kaWithTemplate("cool-temp", deployment(dmWithReplicas(&replicas), dmWithContainerPorts(replicas)))), + d: kubeApp(kaWithTemplate("cool-temp", deployment(dmWithReplicas(&replicas)))), + }, + want: want{ + o: kubeApp(kaWithTemplate("cool-temp", deployment(dmWithReplicas(&replicas)))), + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + err := KubeAppApplyOption()(context.Background(), tc.args.c, tc.args.d) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\nKubeAppApplyOption(...): -want error, +got error\n%s\n", tc.reason, diff) + } + + o, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(tc.want.o) + d, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(tc.args.d) + if diff := cmp.Diff(o, d); diff != "" { + t.Errorf("\n%s\nKubeAppApplyOption(...): -want, +got\n%s\n", tc.reason, diff) + } + }) + } +} diff --git a/pkg/workload/doc.go b/pkg/workload/doc.go new file mode 100644 index 00000000000..bb906e7dc30 --- /dev/null +++ b/pkg/workload/doc.go @@ -0,0 +1,17 @@ +/* +Copyright 2020 The Crossplane Authors. + +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 workload