diff --git a/api/v1alpha1/ingressreplica_types.go b/api/v1alpha1/ingressreplica_types.go new file mode 100644 index 00000000..c970a1db --- /dev/null +++ b/api/v1alpha1/ingressreplica_types.go @@ -0,0 +1,55 @@ +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func init() { + SchemeBuilder.Register(&IngressReplica{}, &IngressReplicaList{}) +} + +// IngressReplicaList contains a list of [IngressReplica] +// +kubebuilder:object:root=true +type IngressReplicaList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []IngressReplica `json:"items"` +} + +// IngressReplica is the Schema for the console ingress replica +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Namespaced +// +kubebuilder:subresource:status +type IngressReplica struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Spec of the IngressReplica + // +kubebuilder:validation:Required + Spec IngressReplicaSpec `json:"spec"` + + // Status of the IngressReplica + // +kubebuilder:validation:Optional + Status Status `json:"status,omitempty"` +} + +type IngressReplicaSpec struct { + // +kubebuilder:validation:Required + IngressRef corev1.ObjectReference `json:"ingressRef"` + + // +kubebuilder:validation:Optional + IngressClassName *string `json:"ingressClassName,omitempty"` + + // +kubebuilder:validation:Optional + TLS []v1.IngressTLS `json:"tls,omitempty"` + + // +kubebuilder:validation:Required + HostMappings map[string]string `json:"hostMappings"` +} + +func (in *IngressReplica) SetCondition(condition metav1.Condition) { + meta.SetStatusCondition(&in.Status.Conditions, condition) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 6cc6cc1d..0b99b29e 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -24,6 +24,7 @@ import ( "github.com/pluralsh/console/go/client" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) @@ -347,6 +348,100 @@ func (in *HelmSpec) DeepCopy() *HelmSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IngressReplica) DeepCopyInto(out *IngressReplica) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressReplica. +func (in *IngressReplica) DeepCopy() *IngressReplica { + if in == nil { + return nil + } + out := new(IngressReplica) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IngressReplica) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IngressReplicaList) DeepCopyInto(out *IngressReplicaList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]IngressReplica, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressReplicaList. +func (in *IngressReplicaList) DeepCopy() *IngressReplicaList { + if in == nil { + return nil + } + out := new(IngressReplicaList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IngressReplicaList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IngressReplicaSpec) DeepCopyInto(out *IngressReplicaSpec) { + *out = *in + out.IngressRef = in.IngressRef + if in.IngressClassName != nil { + in, out := &in.IngressClassName, &out.IngressClassName + *out = new(string) + **out = **in + } + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = make([]networkingv1.IngressTLS, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.HostMappings != nil { + in, out := &in.HostMappings, &out.HostMappings + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressReplicaSpec. +func (in *IngressReplicaSpec) DeepCopy() *IngressReplicaSpec { + if in == nil { + return nil + } + out := new(IngressReplicaSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PipelineGate) DeepCopyInto(out *PipelineGate) { *out = *in diff --git a/charts/deployment-operator/crds/deployments.plural.sh_ingressreplicas.yaml b/charts/deployment-operator/crds/deployments.plural.sh_ingressreplicas.yaml new file mode 100644 index 00000000..ce8969c2 --- /dev/null +++ b/charts/deployment-operator/crds/deployments.plural.sh_ingressreplicas.yaml @@ -0,0 +1,198 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.3 + name: ingressreplicas.deployments.plural.sh +spec: + group: deployments.plural.sh + names: + kind: IngressReplica + listKind: IngressReplicaList + plural: ingressreplicas + singular: ingressreplica + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: IngressReplica is the Schema for the console ingress replica + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Spec of the IngressReplica + properties: + hostMappings: + additionalProperties: + type: string + type: object + ingressClassName: + type: string + ingressRef: + description: ObjectReference contains enough information to let you + inspect or modify the referred object. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: |- + If referring to a piece of an object instead of an entire object, this string + should contain a valid JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within a pod, this would take on a value like: + "spec.containers{name}" (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" (container with + index 2 in this pod). This syntax is chosen only to have some well-defined way of + referencing a part of an object. + type: string + kind: + description: |- + Kind of the referent. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + namespace: + description: |- + Namespace of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ + type: string + resourceVersion: + description: |- + Specific resourceVersion to which this reference is made, if any. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + type: string + uid: + description: |- + UID of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids + type: string + type: object + x-kubernetes-map-type: atomic + tls: + items: + description: IngressTLS describes the transport layer security associated + with an ingress. + properties: + hosts: + description: |- + hosts is a list of hosts included in the TLS certificate. The values in + this list must match the name/s used in the tlsSecret. Defaults to the + wildcard host setting for the loadbalancer controller fulfilling this + Ingress, if left unspecified. + items: + type: string + type: array + x-kubernetes-list-type: atomic + secretName: + description: |- + secretName is the name of the secret used to terminate TLS traffic on + port 443. Field is left optional to allow TLS routing based on SNI + hostname alone. If the SNI host in a listener conflicts with the "Host" + header field used by an IngressRule, the SNI host is used for termination + and value of the "Host" header is used for routing. + type: string + type: object + type: array + required: + - hostMappings + - ingressRef + type: object + status: + description: Status of the IngressReplica + properties: + conditions: + description: Represents the observations of a PrAutomation's current + state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + id: + description: ID of the resource in the Console API. + type: string + sha: + description: SHA of last applied configuration. + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/cmd/agent/kubernetes.go b/cmd/agent/kubernetes.go index 34958e48..9727beab 100644 --- a/cmd/agent/kubernetes.go +++ b/cmd/agent/kubernetes.go @@ -171,6 +171,13 @@ func registerKubeReconcilersOrDie( setupLog.Error(err, "unable to create controller", "controller", "StackRun") } + if err := (&controller.IngressReplicaReconciler{ + Client: manager.GetClient(), + Scheme: manager.GetScheme(), + }).SetupWithManager(manager); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "IngressReplica") + } + rawConsoleUrl, _ := strings.CutSuffix(args.ConsoleUrl(), "/ext/gql") if err := (&controller.VirtualClusterController{ Client: manager.GetClient(), diff --git a/config/crd/bases/deployments.plural.sh_ingressreplicas.yaml b/config/crd/bases/deployments.plural.sh_ingressreplicas.yaml new file mode 100644 index 00000000..ce8969c2 --- /dev/null +++ b/config/crd/bases/deployments.plural.sh_ingressreplicas.yaml @@ -0,0 +1,198 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.3 + name: ingressreplicas.deployments.plural.sh +spec: + group: deployments.plural.sh + names: + kind: IngressReplica + listKind: IngressReplicaList + plural: ingressreplicas + singular: ingressreplica + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: IngressReplica is the Schema for the console ingress replica + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Spec of the IngressReplica + properties: + hostMappings: + additionalProperties: + type: string + type: object + ingressClassName: + type: string + ingressRef: + description: ObjectReference contains enough information to let you + inspect or modify the referred object. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: |- + If referring to a piece of an object instead of an entire object, this string + should contain a valid JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within a pod, this would take on a value like: + "spec.containers{name}" (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" (container with + index 2 in this pod). This syntax is chosen only to have some well-defined way of + referencing a part of an object. + type: string + kind: + description: |- + Kind of the referent. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + namespace: + description: |- + Namespace of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ + type: string + resourceVersion: + description: |- + Specific resourceVersion to which this reference is made, if any. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + type: string + uid: + description: |- + UID of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids + type: string + type: object + x-kubernetes-map-type: atomic + tls: + items: + description: IngressTLS describes the transport layer security associated + with an ingress. + properties: + hosts: + description: |- + hosts is a list of hosts included in the TLS certificate. The values in + this list must match the name/s used in the tlsSecret. Defaults to the + wildcard host setting for the loadbalancer controller fulfilling this + Ingress, if left unspecified. + items: + type: string + type: array + x-kubernetes-list-type: atomic + secretName: + description: |- + secretName is the name of the secret used to terminate TLS traffic on + port 443. Field is left optional to allow TLS routing based on SNI + hostname alone. If the SNI host in a listener conflicts with the "Host" + header field used by an IngressRule, the SNI host is used for termination + and value of the "Host" header is used for routing. + type: string + type: object + type: array + required: + - hostMappings + - ingressRef + type: object + status: + description: Status of the IngressReplica + properties: + conditions: + description: Represents the observations of a PrAutomation's current + state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + id: + description: ID of the resource in the Console API. + type: string + sha: + description: SHA of last applied configuration. + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/internal/controller/ingressreplica_controller.go b/internal/controller/ingressreplica_controller.go new file mode 100644 index 00000000..fb803140 --- /dev/null +++ b/internal/controller/ingressreplica_controller.go @@ -0,0 +1,168 @@ +package controller + +import ( + "context" + + "github.com/pluralsh/deployment-operator/api/v1alpha1" + "github.com/pluralsh/deployment-operator/internal/utils" + "github.com/samber/lo" + networkv1 "k8s.io/api/networking/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + k8sClient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// IngressReplicaReconciler reconciles a IngressReplica resource. +type IngressReplicaReconciler struct { + k8sClient.Client + Scheme *runtime.Scheme +} + +// Reconcile IngressReplica ensure that stays in sync with Kubernetes cluster. +func (r *IngressReplicaReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ reconcile.Result, reterr error) { + logger := log.FromContext(ctx) + + // Read resource from Kubernetes cluster. + ingressReplica := &v1alpha1.IngressReplica{} + if err := r.Get(ctx, req.NamespacedName, ingressReplica); err != nil { + logger.Error(err, "unable to fetch IngressReplica") + return ctrl.Result{}, k8sClient.IgnoreNotFound(err) + } + + logger.Info("reconciling IngressReplica", "namespace", ingressReplica.Namespace, "name", ingressReplica.Name) + utils.MarkCondition(ingressReplica.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionFalse, v1alpha1.ReadyConditionReason, "") + + scope, err := NewDefaultScope(ctx, r.Client, ingressReplica) + if err != nil { + logger.Error(err, "failed to create scope") + utils.MarkCondition(ingressReplica.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionFalse, v1alpha1.ReadyConditionReason, err.Error()) + return ctrl.Result{}, err + } + + // Always patch object when exiting this function, so we can persist any object changes. + defer func() { + if err := scope.PatchObject(); err != nil && reterr == nil { + reterr = err + } + }() + + if !ingressReplica.DeletionTimestamp.IsZero() { + return ctrl.Result{}, nil + } + + oldIngress := &networkv1.Ingress{} + if err := r.Get(ctx, k8sClient.ObjectKey{Name: ingressReplica.Spec.IngressRef.Name, Namespace: ingressReplica.Spec.IngressRef.Namespace}, oldIngress); err != nil { + logger.Error(err, "failed to get old Ingress") + utils.MarkCondition(ingressReplica.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionFalse, v1alpha1.ReadyConditionReason, err.Error()) + return ctrl.Result{}, err + } + + sha, err := utils.HashObject(ingressReplica.Spec) + if err != nil { + logger.Error(err, "failed to hash IngressReplica.Spec") + utils.MarkCondition(ingressReplica.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionFalse, v1alpha1.ReadyConditionReason, err.Error()) + return ctrl.Result{}, err + } + + newIngress := &networkv1.Ingress{} + if err := r.Get(ctx, k8sClient.ObjectKey{Name: ingressReplica.Name, Namespace: ingressReplica.Namespace}, newIngress); err != nil { + if !apierrors.IsNotFound(err) { + logger.Error(err, "failed to get new Ingress") + utils.MarkCondition(ingressReplica.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionFalse, v1alpha1.ReadyConditionReason, err.Error()) + return ctrl.Result{}, err + } + + newIngress = genIngress(ingressReplica, oldIngress) + if err := r.Client.Create(ctx, newIngress); err != nil { + logger.Error(err, "failed to create new Ingress") + utils.MarkCondition(ingressReplica.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionFalse, v1alpha1.ReadyConditionReason, err.Error()) + return ctrl.Result{}, err + } + ingressReplica.Status.SHA = &sha + utils.MarkCondition(ingressReplica.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionTrue, v1alpha1.ReadyConditionReason, "") + return ctrl.Result{}, nil + } + + // update a new ingress + if !ingressReplica.Status.IsSHAEqual(sha) { + updateIngress(ingressReplica, newIngress, oldIngress) + if err := r.Client.Update(ctx, newIngress); err != nil { + logger.Error(err, "failed to update new Ingress") + utils.MarkCondition(ingressReplica.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionFalse, v1alpha1.ReadyConditionReason, err.Error()) + return ctrl.Result{}, err + } + } + ingressReplica.Status.SHA = &sha + utils.MarkCondition(ingressReplica.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionTrue, v1alpha1.ReadyConditionReason, "") + return ctrl.Result{}, reterr +} + +// SetupWithManager sets up the controller with the Manager. +func (r *IngressReplicaReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.IngressReplica{}). + Complete(r) +} + +func genIngress(ingressReplica *v1alpha1.IngressReplica, oldIngress *networkv1.Ingress) *networkv1.Ingress { + newIngress := &networkv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: ingressReplica.Name, + Namespace: ingressReplica.Namespace, + }, + Spec: networkv1.IngressSpec{ + IngressClassName: oldIngress.Spec.IngressClassName, + DefaultBackend: oldIngress.Spec.DefaultBackend, + }, + } + updateIngress(ingressReplica, newIngress, oldIngress) + return newIngress +} + +func updateIngress(ingressReplica *v1alpha1.IngressReplica, newIngress *networkv1.Ingress, oldIngress *networkv1.Ingress) { + if newIngress.Labels == nil { + newIngress.Labels = map[string]string{} + } + if oldIngress.Labels == nil { + oldIngress.Labels = map[string]string{} + } + if ingressReplica.Labels == nil { + ingressReplica.Labels = map[string]string{} + } + // merge from left to right + newIngress.Labels = lo.Assign(newIngress.Labels, oldIngress.Labels, ingressReplica.Labels) + + if newIngress.Annotations == nil { + newIngress.Annotations = map[string]string{} + } + if oldIngress.Annotations == nil { + oldIngress.Annotations = map[string]string{} + } + if ingressReplica.Annotations == nil { + ingressReplica.Annotations = map[string]string{} + } + // merge from left to right + newIngress.Annotations = lo.Assign(newIngress.Annotations, oldIngress.Annotations, ingressReplica.Annotations) + + if ingressReplica.Spec.IngressClassName != nil { + newIngress.Spec.IngressClassName = ingressReplica.Spec.IngressClassName + } + if len(ingressReplica.Spec.TLS) > 0 { + newIngress.Spec.TLS = ingressReplica.Spec.TLS + } + for _, rule := range oldIngress.Spec.Rules { + ir := networkv1.IngressRule{ + Host: rule.Host, + IngressRuleValue: rule.IngressRuleValue, + } + if newHost, ok := ingressReplica.Spec.HostMappings[rule.Host]; ok { + ir.Host = newHost + } + + newIngress.Spec.Rules = append(newIngress.Spec.Rules, ir) + } +} diff --git a/internal/controller/ingressreplica_controller_test.go b/internal/controller/ingressreplica_controller_test.go new file mode 100644 index 00000000..48d00f2a --- /dev/null +++ b/internal/controller/ingressreplica_controller_test.go @@ -0,0 +1,161 @@ +package controller + +import ( + "context" + "sort" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pluralsh/deployment-operator/api/v1alpha1" + "github.com/pluralsh/deployment-operator/pkg/test/common" + "github.com/samber/lo" + corev1 "k8s.io/api/core/v1" + networkv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +var _ = Describe("IngressReplica Controller", Ordered, func() { + Context("When reconciling a resource", func() { + const ( + ingressReplicaName = "ingress-replica-name" + ingressName = "old-ingress" + namespace = "default" + ) + + ctx := context.Background() + + namespacedName := types.NamespacedName{Name: ingressReplicaName, Namespace: namespace} + ingressNamespacedName := types.NamespacedName{Name: ingressName, Namespace: namespace} + + ingressReplica := &v1alpha1.IngressReplica{} + oldIngress := &networkv1.Ingress{} + + BeforeAll(func() { + By("Creating IngressReplica") + err := kClient.Get(ctx, namespacedName, ingressReplica) + if err != nil && errors.IsNotFound(err) { + resource := &v1alpha1.IngressReplica{ + ObjectMeta: metav1.ObjectMeta{ + Name: ingressReplicaName, + Namespace: namespace, + }, + Spec: v1alpha1.IngressReplicaSpec{ + IngressRef: corev1.ObjectReference{ + Name: ingressName, + Namespace: namespace, + }, + HostMappings: map[string]string{ + "example.com": "test.example.com", + }, + }, + } + Expect(kClient.Create(ctx, resource)).To(Succeed()) + } + By("Creating Ingress") + err = kClient.Get(ctx, ingressNamespacedName, oldIngress) + if err != nil && errors.IsNotFound(err) { + resource := &networkv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: ingressName, + Namespace: namespace, + }, + Spec: networkv1.IngressSpec{ + Rules: []networkv1.IngressRule{ + { + Host: "test", + IngressRuleValue: networkv1.IngressRuleValue{}, + }, + }, + }, + } + Expect(kClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterAll(func() { + By("Cleanup ingress replica resources") + oldIngress := &networkv1.Ingress{} + Expect(kClient.Get(ctx, ingressNamespacedName, oldIngress)).NotTo(HaveOccurred()) + Expect(kClient.Delete(ctx, oldIngress)).To(Succeed()) + + By("Cleanup ingress replica") + ingressReplica := &v1alpha1.IngressReplica{} + Expect(kClient.Get(ctx, namespacedName, ingressReplica)).NotTo(HaveOccurred()) + Expect(kClient.Delete(ctx, ingressReplica)).To(Succeed()) + }) + + It("create ingress", func() { + reconciler := &IngressReplicaReconciler{ + Client: kClient, + Scheme: kClient.Scheme(), + } + _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: namespacedName}) + Expect(err).NotTo(HaveOccurred()) + + newIngress := &networkv1.Ingress{} + Expect(kClient.Get(ctx, namespacedName, newIngress)).NotTo(HaveOccurred()) + + err = kClient.Get(ctx, namespacedName, ingressReplica) + Expect(err).NotTo(HaveOccurred()) + Expect(SanitizeStatusConditions(ingressReplica.Status)).To(Equal(SanitizeStatusConditions(v1alpha1.Status{ + SHA: lo.ToPtr("ACBBWIKK74ACGAK5NWAXYTTIYI2GDOSXGCJ65UGOLOPFCB24PKUQ===="), + Conditions: []metav1.Condition{ + { + Type: v1alpha1.ReadyConditionType.String(), + Status: metav1.ConditionTrue, + Reason: v1alpha1.ReadyConditionReason.String(), + Message: "", + }, + }, + }))) + + }) + + It("update ingress", func() { + reconciler := &IngressReplicaReconciler{ + Client: kClient, + Scheme: kClient.Scheme(), + } + + Expect(common.MaybePatch(kClient, &v1alpha1.IngressReplica{ + ObjectMeta: metav1.ObjectMeta{Name: ingressReplicaName, Namespace: namespace}, + }, func(p *v1alpha1.IngressReplica) { + p.Status.SHA = lo.ToPtr("diff-sha") + })).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: namespacedName}) + Expect(err).NotTo(HaveOccurred()) + err = kClient.Get(ctx, namespacedName, ingressReplica) + Expect(err).NotTo(HaveOccurred()) + Expect(SanitizeStatusConditions(ingressReplica.Status)).To(Equal(SanitizeStatusConditions(v1alpha1.Status{ + SHA: lo.ToPtr("ACBBWIKK74ACGAK5NWAXYTTIYI2GDOSXGCJ65UGOLOPFCB24PKUQ===="), + Conditions: []metav1.Condition{ + { + Type: v1alpha1.ReadyConditionType.String(), + Status: metav1.ConditionTrue, + Reason: v1alpha1.ReadyConditionReason.String(), + Message: "", + }, + }, + }))) + + }) + + }) +}) + +func SanitizeStatusConditions(status v1alpha1.Status) v1alpha1.Status { + for i := range status.Conditions { + status.Conditions[i].LastTransitionTime = metav1.Time{} + status.Conditions[i].ObservedGeneration = 0 + } + + sort.Slice(status.Conditions, func(i, j int) bool { + return status.Conditions[i].Type < status.Conditions[j].Type + }) + + return status +} diff --git a/pkg/manifests/template/helm_test.go b/pkg/manifests/template/helm_test.go index 63b97962..699ee1fc 100644 --- a/pkg/manifests/template/helm_test.go +++ b/pkg/manifests/template/helm_test.go @@ -64,7 +64,7 @@ var _ = Describe("Helm template", func() { It("should successfully render the helm template", func() { resp, err := NewHelm(dir).Render(svc, utilFactory) Expect(err).NotTo(HaveOccurred()) - Expect(len(resp)).To(Equal(12)) + Expect(len(resp)).To(Equal(13)) }) })