From d62b005df6b9f0580d83d35c3d65153e27fce618 Mon Sep 17 00:00:00 2001 From: Luke Kysow <1034429+lkysow@users.noreply.github.com> Date: Tue, 15 Sep 2020 16:47:48 -0700 Subject: [PATCH] ServiceResolver controller and webhook * Also make controller and webhook code generic * Update to controller-runtime 0.6.3 to fix spurious log error message. --- api/common/common.go | 2 + api/common/configentry.go | 50 ++ api/common/configentry_webhook.go | 56 ++ api/common/configentry_webhook_test.go | 161 +++++ api/v1alpha1/groupversion_info.go | 2 + api/v1alpha1/servicedefaults_types.go | 109 +++- api/v1alpha1/servicedefaults_types_test.go | 58 +- api/v1alpha1/servicedefaults_webhook.go | 40 +- api/v1alpha1/servicedefaults_webhook_test.go | 99 ---- api/v1alpha1/serviceresolver_types.go | 338 +++++++++++ api/v1alpha1/serviceresolver_types_test.go | 548 ++++++++++++++++++ api/v1alpha1/serviceresolver_webhook.go | 67 +++ api/v1alpha1/status.go | 3 - api/v1alpha1/zz_generated.deepcopy.go | 177 +++++- .../consul.hashicorp.com_servicedefaults.yaml | 1 - ...consul.hashicorp.com_serviceresolvers.yaml | 175 ++++++ config/crd/kustomization.yaml | 5 +- .../cainjection_in_serviceresolvers.yaml | 8 + .../patches/webhook_in_servicedefaults.yaml | 20 +- .../patches/webhook_in_serviceresolvers.yaml | 17 + config/rbac/role.yaml | 20 + config/rbac/serviceresolver_editor_role.yaml | 24 + config/rbac/serviceresolver_viewer_role.yaml | 20 + .../consul_v1alpha1_serviceresolver.yaml | 7 + config/webhook/manifests.yaml | 18 + controllers/configentry_controller.go | 234 ++++++++ ....go => configentry_controller_ent_test.go} | 89 +-- controllers/configentry_controller_test.go | 482 +++++++++++++++ controllers/servicedefaults_controller.go | 251 +------- .../servicedefaults_controller_test.go | 314 ---------- controllers/serviceresolver_controller.go | 42 ++ go.mod | 8 +- go.sum | 33 ++ subcommand/controller/command.go | 26 +- 34 files changed, 2743 insertions(+), 761 deletions(-) create mode 100644 api/common/common.go create mode 100644 api/common/configentry.go create mode 100644 api/common/configentry_webhook.go create mode 100644 api/common/configentry_webhook_test.go delete mode 100644 api/v1alpha1/servicedefaults_webhook_test.go create mode 100644 api/v1alpha1/serviceresolver_types.go create mode 100644 api/v1alpha1/serviceresolver_types_test.go create mode 100644 api/v1alpha1/serviceresolver_webhook.go create mode 100644 config/crd/bases/consul.hashicorp.com_serviceresolvers.yaml create mode 100644 config/crd/patches/cainjection_in_serviceresolvers.yaml create mode 100644 config/crd/patches/webhook_in_serviceresolvers.yaml create mode 100644 config/rbac/serviceresolver_editor_role.yaml create mode 100644 config/rbac/serviceresolver_viewer_role.yaml create mode 100644 config/samples/consul_v1alpha1_serviceresolver.yaml create mode 100644 controllers/configentry_controller.go rename controllers/{servicedefaults_controller_ent_test.go => configentry_controller_ent_test.go} (82%) create mode 100644 controllers/configentry_controller_test.go delete mode 100644 controllers/servicedefaults_controller_test.go create mode 100644 controllers/serviceresolver_controller.go diff --git a/api/common/common.go b/api/common/common.go new file mode 100644 index 0000000000..669f8c0d5b --- /dev/null +++ b/api/common/common.go @@ -0,0 +1,2 @@ +// Package common holds code that isn't tied to a particular CRD version or type. +package common diff --git a/api/common/configentry.go b/api/common/configentry.go new file mode 100644 index 0000000000..343642ca6f --- /dev/null +++ b/api/common/configentry.go @@ -0,0 +1,50 @@ +package common + +import ( + "github.com/hashicorp/consul/api" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// ConfigEntryResource is a generic config entry custom resource. It is implemented +// by each config entry type so that they can be acted upon generically. +// It is not tied to a specific CRD version. +type ConfigEntryResource interface { + // GetObjectMeta returns object meta. + GetObjectMeta() metav1.ObjectMeta + // AddFinalizer adds a finalizer to the list of finalizers. + AddFinalizer(name string) + // RemoveFinalizer removes this finalizer from the list. + RemoveFinalizer(name string) + // Finalizers returns the list of finalizers for this object. + Finalizers() []string + // ConsulKind returns the Consul config entry kind, i.e. service-defaults, not + // servicedefaults. + ConsulKind() string + // KubeKind returns the Kube config entry kind, i.e. servicedefaults, not + // service-defaults. + KubeKind() string + // Name returns the name of the config entry. + Name() string + // SetSyncedCondition updates the synced condition. + SetSyncedCondition(status corev1.ConditionStatus, reason, message string) + // SyncedCondition gets the synced condition. + SyncedCondition() (status corev1.ConditionStatus, reason, message string) + // SyncedConditionStatus returns the status of the synced condition. + SyncedConditionStatus() corev1.ConditionStatus + // ToConsul converts the resource to the corresponding Consul API definition. + // Its return type is the generic ConfigEntry but a specific config entry + // type should be constructed e.g. ServiceConfigEntry. + ToConsul() api.ConfigEntry + // MatchesConsul returns true if the resource has the same fields as the Consul + // config entry. + MatchesConsul(candidate api.ConfigEntry) bool + // GetObjectKind should be implemented by the generated code. + GetObjectKind() schema.ObjectKind + // DeepCopyObject should be implemented by the generated code. + DeepCopyObject() runtime.Object + // Validate returns an error if the resource is invalid. + Validate() error +} diff --git a/api/common/configentry_webhook.go b/api/common/configentry_webhook.go new file mode 100644 index 0000000000..5de1ed2515 --- /dev/null +++ b/api/common/configentry_webhook.go @@ -0,0 +1,56 @@ +package common + +import ( + "context" + "fmt" + "net/http" + + "github.com/go-logr/logr" + "k8s.io/api/admission/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// ConfigEntryLister is implemented by CRD-specific webhooks. +type ConfigEntryLister interface { + // List returns all resources of this type across all namespaces in a + // Kubernetes cluster. + List(ctx context.Context) ([]ConfigEntryResource, error) +} + +// ValidateConfigEntry validates cfgEntry. It is a generic method that +// can be used by all CRD-specific validators. +// Callers should pass themselves as validator and kind should be the custom +// resource name, e.g. "ServiceDefaults". +func ValidateConfigEntry( + ctx context.Context, + req admission.Request, + logger logr.Logger, + configEntryLister ConfigEntryLister, + cfgEntry ConfigEntryResource) admission.Response { + + // On create we need to validate that there isn't already a resource with + // the same name in a different namespace since we need to map all Kube + // resources to a single Consul namespace. + if req.Operation == v1beta1.Create { + logger.Info("validate create", "name", cfgEntry.Name()) + + list, err := configEntryLister.List(ctx) + if err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + for _, item := range list { + if item.Name() == cfgEntry.Name() { + // todo: If running Consul Ent with mirroring need to change this to respect namespaces. + return admission.Errored(http.StatusBadRequest, + fmt.Errorf("%s resource with name %q is already defined – all %s resources must have unique names across namespaces", + cfgEntry.KubeKind(), + cfgEntry.Name(), + cfgEntry.KubeKind())) + } + } + } + if err := cfgEntry.Validate(); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + return admission.Allowed(fmt.Sprintf("valid %s request", cfgEntry.KubeKind())) +} diff --git a/api/common/configentry_webhook_test.go b/api/common/configentry_webhook_test.go new file mode 100644 index 0000000000..51f91108b2 --- /dev/null +++ b/api/common/configentry_webhook_test.go @@ -0,0 +1,161 @@ +package common + +import ( + "context" + "encoding/json" + "errors" + "testing" + + logrtest "github.com/go-logr/logr/testing" + capi "github.com/hashicorp/consul/api" + "github.com/stretchr/testify/require" + "k8s.io/api/admission/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +func TestValidateConfigEntry(t *testing.T) { + otherNS := "other" + + cases := map[string]struct { + existingResources []ConfigEntryResource + newResource ConfigEntryResource + expAllow bool + expErrMessage string + }{ + "no duplicates, valid": { + existingResources: nil, + newResource: &mockConfigEntry{ + MockName: "foo", + MockNamespace: otherNS, + Valid: true, + }, + expAllow: true, + }, + "no duplicates, invalid": { + existingResources: nil, + newResource: &mockConfigEntry{ + MockName: "foo", + MockNamespace: otherNS, + Valid: false, + }, + expAllow: false, + expErrMessage: "invalid", + }, + "duplicate name": { + existingResources: []ConfigEntryResource{&mockConfigEntry{ + MockName: "foo", + MockNamespace: "default", + }}, + newResource: &mockConfigEntry{ + MockName: "foo", + MockNamespace: otherNS, + Valid: true, + }, + expAllow: false, + expErrMessage: "mockkind resource with name \"foo\" is already defined – all mockkind resources must have unique names across namespaces", + }, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + marshalledRequestObject, err := json.Marshal(c.newResource) + require.NoError(t, err) + + lister := &mockConfigEntryLister{ + Resources: c.existingResources, + } + response := ValidateConfigEntry(ctx, admission.Request{ + AdmissionRequest: v1beta1.AdmissionRequest{ + Name: c.newResource.Name(), + Namespace: otherNS, + Operation: v1beta1.Create, + Object: runtime.RawExtension{ + Raw: marshalledRequestObject, + }, + }, + }, + logrtest.TestLogger{T: t}, + lister, + c.newResource) + require.Equal(t, c.expAllow, response.Allowed) + if c.expErrMessage != "" { + require.Equal(t, c.expErrMessage, response.AdmissionResponse.Result.Message) + } + }) + } +} + +type mockConfigEntryLister struct { + Resources []ConfigEntryResource +} + +func (in *mockConfigEntryLister) List(_ context.Context) ([]ConfigEntryResource, error) { + return in.Resources, nil +} + +type mockConfigEntry struct { + MockName string + MockNamespace string + Valid bool +} + +func (in *mockConfigEntry) GetObjectMeta() metav1.ObjectMeta { + return metav1.ObjectMeta{} +} + +func (in *mockConfigEntry) GetObjectKind() schema.ObjectKind { + return schema.EmptyObjectKind +} + +func (in *mockConfigEntry) DeepCopyObject() runtime.Object { + return in +} + +func (in *mockConfigEntry) AddFinalizer(_ string) {} + +func (in *mockConfigEntry) RemoveFinalizer(_ string) {} + +func (in *mockConfigEntry) Finalizers() []string { + return nil +} + +func (in *mockConfigEntry) ConsulKind() string { + return "mock-kind" +} + +func (in *mockConfigEntry) KubeKind() string { + return "mockkind" +} + +func (in *mockConfigEntry) Name() string { + return in.MockName +} + +func (in *mockConfigEntry) SetSyncedCondition(_ corev1.ConditionStatus, _ string, _ string) {} + +func (in *mockConfigEntry) SyncedCondition() (status corev1.ConditionStatus, reason string, message string) { + return corev1.ConditionTrue, "", "" +} + +func (in *mockConfigEntry) SyncedConditionStatus() corev1.ConditionStatus { + return corev1.ConditionTrue +} + +func (in *mockConfigEntry) ToConsul() capi.ConfigEntry { + return &capi.ServiceConfigEntry{} +} + +func (in *mockConfigEntry) Validate() error { + if !in.Valid { + return errors.New("invalid") + } + return nil +} + +func (in *mockConfigEntry) MatchesConsul(_ capi.ConfigEntry) bool { + return false +} diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go index 200e710959..b6054efb6f 100644 --- a/api/v1alpha1/groupversion_info.go +++ b/api/v1alpha1/groupversion_info.go @@ -8,6 +8,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/scheme" ) +const ConsulHashicorpGroup string = "consul.hashicorp.com" + var ( // GroupVersion is group version used to register these objects GroupVersion = schema.GroupVersion{Group: "consul.hashicorp.com", Version: "v1alpha1"} diff --git a/api/v1alpha1/servicedefaults_types.go b/api/v1alpha1/servicedefaults_types.go index 5a22df8604..ff90b6b869 100644 --- a/api/v1alpha1/servicedefaults_types.go +++ b/api/v1alpha1/servicedefaults_types.go @@ -5,6 +5,7 @@ import ( "strings" capi "github.com/hashicorp/consul/api" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" @@ -12,10 +13,21 @@ import ( ) const ( - ConsulHashicorpGroup string = "consul.hashicorp.com" - ServiceDefaultsKind string = "servicedefaults" + ServiceDefaultsKubeKind string = "servicedefaults" ) +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// ServiceDefaults is the Schema for the servicedefaults API +// +kubebuilder:printcolumn:name="Synced",type="string",JSONPath=".status.conditions[?(@.type==\"Synced\")].status",description="The sync status of the resource with Consul" +type ServiceDefaults struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec ServiceDefaultsSpec `json:"spec,omitempty"` + Status `json:"status,omitempty"` +} + // ServiceDefaultsSpec defines the desired state of ServiceDefaults type ServiceDefaultsSpec struct { // Protocol sets the protocol of the service. This is used by Connect proxies for @@ -31,22 +43,59 @@ type ServiceDefaultsSpec struct { ExternalSNI string `json:"externalSNI,omitempty"` } -// ServiceDefaultsStatus defines the observed state of ServiceDefaults -type ServiceDefaultsStatus struct { - Status `json:",inline"` +func (in *ServiceDefaults) ConsulKind() string { + return capi.ServiceDefaults } -// +kubebuilder:object:root=true -// +kubebuilder:subresource:status +func (in *ServiceDefaults) KubeKind() string { + return ServiceDefaultsKubeKind +} -// ServiceDefaults is the Schema for the servicedefaults API -// +kubebuilder:printcolumn:name="Synced",type="string",JSONPath=".status.conditions[?(@.type==\"Synced\")].status",description="The sync status of the resource with Consul" -type ServiceDefaults struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` +func (in *ServiceDefaults) GetObjectMeta() metav1.ObjectMeta { + return in.ObjectMeta +} + +func (in *ServiceDefaults) AddFinalizer(f string) { + in.ObjectMeta.Finalizers = append(in.Finalizers(), f) +} - Spec ServiceDefaultsSpec `json:"spec,omitempty"` - Status ServiceDefaultsStatus `json:"status,omitempty"` +func (in *ServiceDefaults) RemoveFinalizer(f string) { + var newFinalizers []string + for _, oldF := range in.Finalizers() { + if oldF != f { + newFinalizers = append(newFinalizers, oldF) + } + } + in.ObjectMeta.Finalizers = newFinalizers +} + +func (in *ServiceDefaults) Finalizers() []string { + return in.ObjectMeta.Finalizers +} + +func (in *ServiceDefaults) Name() string { + return in.ObjectMeta.Name +} + +func (in *ServiceDefaults) SetSyncedCondition(status corev1.ConditionStatus, reason string, message string) { + in.Status.Conditions = Conditions{ + { + Type: ConditionSynced, + Status: status, + LastTransitionTime: metav1.Now(), + Reason: reason, + Message: message, + }, + } +} + +func (in *ServiceDefaults) SyncedCondition() (status corev1.ConditionStatus, reason string, message string) { + cond := in.Status.GetCondition(ConditionSynced) + return cond.Status, cond.Reason, cond.Message +} + +func (in *ServiceDefaults) SyncedConditionStatus() corev1.ConditionStatus { + return in.Status.GetCondition(ConditionSynced).Status } // +kubebuilder:object:root=true @@ -63,10 +112,10 @@ func init() { } // ToConsul converts the entry into it's Consul equivalent struct. -func (in *ServiceDefaults) ToConsul() *capi.ServiceConfigEntry { +func (in *ServiceDefaults) ToConsul() capi.ConfigEntry { return &capi.ServiceConfigEntry{ - Kind: capi.ServiceDefaults, - Name: in.Name, + Kind: in.ConsulKind(), + Name: in.Name(), Protocol: in.Spec.Protocol, MeshGateway: in.Spec.MeshGateway.toConsul(), Expose: in.Spec.Expose.toConsul(), @@ -74,15 +123,6 @@ func (in *ServiceDefaults) ToConsul() *capi.ServiceConfigEntry { } } -// MatchesConsul returns true if entry has the same config as this struct. -func (in *ServiceDefaults) MatchesConsul(entry *capi.ServiceConfigEntry) bool { - return in.Name == entry.GetName() && - in.Spec.Protocol == entry.Protocol && - in.Spec.MeshGateway.Mode == string(entry.MeshGateway.Mode) && - in.Spec.Expose.matches(entry.Expose) && - in.Spec.ExternalSNI == entry.ExternalSNI -} - // Validate validates the fields provided in the spec of the ServiceDefaults and // returns an error which lists all invalid fields in the resource spec. func (in *ServiceDefaults) Validate() error { @@ -94,13 +134,26 @@ func (in *ServiceDefaults) Validate() error { if len(allErrs) > 0 { return apierrors.NewInvalid( - schema.GroupKind{Group: ConsulHashicorpGroup, Kind: ServiceDefaultsKind}, - in.Name, allErrs) + schema.GroupKind{Group: ConsulHashicorpGroup, Kind: ServiceDefaultsKubeKind}, + in.Name(), allErrs) } return nil } +// MatchesConsul returns true if entry has the same config as this struct. +func (in *ServiceDefaults) MatchesConsul(candidate capi.ConfigEntry) bool { + serviceDefaultsCandidate, ok := candidate.(*capi.ServiceConfigEntry) + if !ok { + return false + } + return in.Name() == serviceDefaultsCandidate.Name && + in.Spec.Protocol == serviceDefaultsCandidate.Protocol && + in.Spec.MeshGateway.Mode == string(serviceDefaultsCandidate.MeshGateway.Mode) && + in.Spec.Expose.matches(serviceDefaultsCandidate.Expose) && + in.Spec.ExternalSNI == serviceDefaultsCandidate.ExternalSNI +} + // ExposeConfig describes HTTP paths to expose through Envoy outside of Connect. // Users can expose individual paths and/or all HTTP/GRPC paths for checks. type ExposeConfig struct { diff --git a/api/v1alpha1/servicedefaults_types_test.go b/api/v1alpha1/servicedefaults_types_test.go index a25af188a5..92065c8cf4 100644 --- a/api/v1alpha1/servicedefaults_types_test.go +++ b/api/v1alpha1/servicedefaults_types_test.go @@ -5,6 +5,7 @@ import ( capi "github.com/hashicorp/consul/api" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -655,7 +656,7 @@ func TestMatchesConsul(t *testing.T) { } } -func TestValidate(t *testing.T) { +func TestServiceDefaults_Validate(t *testing.T) { cases := map[string]struct { input *ServiceDefaults expectedErrMsg string @@ -739,3 +740,58 @@ func TestValidate(t *testing.T) { }) } } + +func TestServiceDefaults_AddFinalizer(t *testing.T) { + serviceDefaults := &ServiceDefaults{} + serviceDefaults.AddFinalizer("finalizer") + require.Equal(t, []string{"finalizer"}, serviceDefaults.ObjectMeta.Finalizers) +} + +func TestServiceDefaults_RemoveFinalizer(t *testing.T) { + serviceDefaults := &ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Finalizers: []string{"f1", "f2"}, + }, + } + serviceDefaults.RemoveFinalizer("f1") + require.Equal(t, []string{"f2"}, serviceDefaults.ObjectMeta.Finalizers) +} + +func TestServiceDefaults_SetSyncedCondition(t *testing.T) { + serviceDefaults := &ServiceDefaults{} + serviceDefaults.SetSyncedCondition(corev1.ConditionTrue, "reason", "message") + + require.Equal(t, corev1.ConditionTrue, serviceDefaults.Status.Conditions[0].Status) + require.Equal(t, "reason", serviceDefaults.Status.Conditions[0].Reason) + require.Equal(t, "message", serviceDefaults.Status.Conditions[0].Message) + now := metav1.Now() + require.True(t, serviceDefaults.Status.Conditions[0].LastTransitionTime.Before(&now)) +} + +func TestServiceDefaults_GetSyncedConditionStatus(t *testing.T) { + cases := []corev1.ConditionStatus{ + corev1.ConditionUnknown, + corev1.ConditionFalse, + corev1.ConditionTrue, + } + for _, status := range cases { + t.Run(string(status), func(t *testing.T) { + serviceDefaults := &ServiceDefaults{ + Status: Status{ + Conditions: []Condition{{ + Type: ConditionSynced, + Status: status, + }}, + }, + } + + require.Equal(t, status, serviceDefaults.SyncedConditionStatus()) + }) + } +} + +// Test that if status is empty then GetCondition returns nil. +func TestServiceDefaults_GetConditionWhenNil(t *testing.T) { + serviceDefaults := &ServiceDefaults{} + require.Nil(t, serviceDefaults.GetCondition(ConditionSynced)) +} diff --git a/api/v1alpha1/servicedefaults_webhook.go b/api/v1alpha1/servicedefaults_webhook.go index 12ae775e36..2750a93c7a 100644 --- a/api/v1alpha1/servicedefaults_webhook.go +++ b/api/v1alpha1/servicedefaults_webhook.go @@ -2,12 +2,11 @@ package v1alpha1 import ( "context" - "fmt" "net/http" "github.com/go-logr/logr" + "github.com/hashicorp/consul-k8s/api/common" capi "github.com/hashicorp/consul/api" - "k8s.io/api/admission/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" ) @@ -27,7 +26,12 @@ type serviceDefaultsValidator struct { decoder *admission.Decoder } -// Note: The path value in the below line is the path to the webhook. If it is updates, run code-gen, update subcommand/controller/command.go and the consul-helm value for the path to the webhook. +// NOTE: The path value in the below line is the path to the webhook. +// If it is updated, run code-gen, update subcommand/controller/command.go +// and the consul-helm value for the path to the webhook. +// +// NOTE: The below line cannot be combined with any other comment. If it is it will break the code generation. +// // +kubebuilder:webhook:verbs=create;update,path=/mutate-v1alpha1-servicedefaults,mutating=true,failurePolicy=fail,groups=consul.hashicorp.com,resources=servicedefaults,versions=v1alpha1,name=mutate-servicedefaults.consul.hashicorp.com func (v *serviceDefaultsValidator) Handle(ctx context.Context, req admission.Request) admission.Response { @@ -37,23 +41,23 @@ func (v *serviceDefaultsValidator) Handle(ctx context.Context, req admission.Req return admission.Errored(http.StatusBadRequest, err) } - if req.Operation == v1beta1.Create { - v.Logger.Info("validate create", "name", svcDefaults.Name) - var svcDefaultsList ServiceDefaultsList - if err := v.Client.List(context.Background(), &svcDefaultsList); err != nil { - return admission.Errored(http.StatusInternalServerError, err) - } - for _, item := range svcDefaultsList.Items { - if item.Name == svcDefaults.Name { - return admission.Errored(http.StatusBadRequest, fmt.Errorf("ServiceDefaults resource with name %q is already defined – all ServiceDefaults resources must have unique names across namespaces", - svcDefaults.Name)) - } - } + return common.ValidateConfigEntry(ctx, + req, + v.Logger, + v, + &svcDefaults) +} + +func (v *serviceDefaultsValidator) List(ctx context.Context) ([]common.ConfigEntryResource, error) { + var svcDefaultsList ServiceDefaultsList + if err := v.Client.List(ctx, &svcDefaultsList); err != nil { + return nil, err } - if err := svcDefaults.Validate(); err != nil { - return admission.Errored(http.StatusBadRequest, err) + var entries []common.ConfigEntryResource + for _, item := range svcDefaultsList.Items { + entries = append(entries, common.ConfigEntryResource(&item)) } - return admission.Allowed("Valid Service Defaults Request") + return entries, nil } func (v *serviceDefaultsValidator) InjectDecoder(d *admission.Decoder) error { diff --git a/api/v1alpha1/servicedefaults_webhook_test.go b/api/v1alpha1/servicedefaults_webhook_test.go deleted file mode 100644 index 2b7855f967..0000000000 --- a/api/v1alpha1/servicedefaults_webhook_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package v1alpha1 - -import ( - "context" - "encoding/json" - "testing" - - logrtest "github.com/go-logr/logr/testing" - capi "github.com/hashicorp/consul/api" - "github.com/hashicorp/consul/sdk/testutil" - "github.com/stretchr/testify/require" - "k8s.io/api/admission/v1beta1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/kubernetes/scheme" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - "sigs.k8s.io/controller-runtime/pkg/webhook/admission" -) - -func TestRun_HandleErrorsIfServiceDefaultsWithSameNameExists(t *testing.T) { - svcDefaults := &ServiceDefaults{ - ObjectMeta: metav1.ObjectMeta{ - Name: "foo", - Namespace: "default", - }, - Spec: ServiceDefaultsSpec{ - Protocol: "http", - }, - } - s := scheme.Scheme - s.AddKnownTypes(GroupVersion, svcDefaults) - s.AddKnownTypes(GroupVersion, &ServiceDefaultsList{}) - ctx := context.Background() - - consul, err := testutil.NewTestServerConfigT(t, nil) - require.NoError(t, err) - defer consul.Stop() - consulClient, err := capi.NewClient(&capi.Config{ - Address: consul.HTTPAddr, - }) - require.NoError(t, err) - - client := fake.NewFakeClientWithScheme(s, svcDefaults) - - validator := &serviceDefaultsValidator{ - Client: client, - ConsulClient: consulClient, - Logger: logrtest.TestLogger{T: t}, - } - - decoder, err := admission.NewDecoder(scheme.Scheme) - require.NoError(t, err) - err = validator.InjectDecoder(decoder) - require.NoError(t, err) - - requestObject := &ServiceDefaults{ - ObjectMeta: metav1.ObjectMeta{ - Name: "foo", - Namespace: "other-namespace", - }, - Spec: ServiceDefaultsSpec{ - Protocol: "http", - }, - } - marshalledRequestObject, err := json.Marshal(requestObject) - require.NoError(t, err) - - response := validator.Handle(ctx, admission.Request{ - AdmissionRequest: v1beta1.AdmissionRequest{ - Kind: metav1.GroupVersionKind{ - Group: GroupVersion.Group, - Version: GroupVersion.Version, - Kind: "servicedefaults", - }, - Resource: metav1.GroupVersionResource{ - Group: GroupVersion.Group, - Version: GroupVersion.Version, - Resource: "servicedefaults", - }, - RequestKind: &metav1.GroupVersionKind{ - Group: GroupVersion.Group, - Version: GroupVersion.Version, - Kind: "servicedefaults", - }, - RequestResource: &metav1.GroupVersionResource{ - Group: GroupVersion.Group, - Version: GroupVersion.Version, - Resource: "servicedefaults", - }, - Name: "foo", - Namespace: "other-namespace", - Operation: v1beta1.Create, - Object: runtime.RawExtension{ - Raw: marshalledRequestObject, - }, - }, - }) - require.False(t, response.Allowed) -} diff --git a/api/v1alpha1/serviceresolver_types.go b/api/v1alpha1/serviceresolver_types.go new file mode 100644 index 0000000000..b8ba5bdd56 --- /dev/null +++ b/api/v1alpha1/serviceresolver_types.go @@ -0,0 +1,338 @@ +package v1alpha1 + +import ( + "fmt" + "reflect" + "sort" + "time" + + capi "github.com/hashicorp/consul/api" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +const ServiceResolverKubeKind string = "serviceresolver" + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// ServiceResolver is the Schema for the serviceresolvers API +// +kubebuilder:printcolumn:name="Synced",type="string",JSONPath=".status.conditions[?(@.type==\"Synced\")].status",description="The sync status of the resource with Consul" +type ServiceResolver struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec ServiceResolverSpec `json:"spec,omitempty"` + Status `json:"status,omitempty"` +} + +func (in *ServiceResolver) ConsulKind() string { + return capi.ServiceResolver +} + +func (in *ServiceResolver) KubeKind() string { + return ServiceResolverKubeKind +} + +func (in *ServiceResolver) GetObjectMeta() metav1.ObjectMeta { + return in.ObjectMeta +} + +func (in *ServiceResolver) AddFinalizer(f string) { + in.ObjectMeta.Finalizers = append(in.Finalizers(), f) +} + +func (in *ServiceResolver) RemoveFinalizer(f string) { + var newFinalizers []string + for _, oldF := range in.Finalizers() { + if oldF != f { + newFinalizers = append(newFinalizers, oldF) + } + } + in.ObjectMeta.Finalizers = newFinalizers +} + +func (in *ServiceResolver) Finalizers() []string { + return in.ObjectMeta.Finalizers +} + +func (in *ServiceResolver) Name() string { + return in.ObjectMeta.Name +} + +func (in *ServiceResolver) SetSyncedCondition(status corev1.ConditionStatus, reason string, message string) { + in.Status.Conditions = Conditions{ + { + Type: ConditionSynced, + Status: status, + LastTransitionTime: metav1.Now(), + Reason: reason, + Message: message, + }, + } +} + +func (in *ServiceResolver) SyncedCondition() (status corev1.ConditionStatus, reason string, message string) { + cond := in.Status.GetCondition(ConditionSynced) + return cond.Status, cond.Reason, cond.Message +} + +func (in *ServiceResolver) SyncedConditionStatus() corev1.ConditionStatus { + return in.Status.GetCondition(ConditionSynced).Status +} + +// ToConsul converts the entry into its Consul equivalent struct. +func (in *ServiceResolver) ToConsul() capi.ConfigEntry { + return &capi.ServiceResolverConfigEntry{ + Kind: in.ConsulKind(), + Name: in.Name(), + DefaultSubset: in.Spec.DefaultSubset, + Subsets: in.Spec.Subsets.toConsul(), + Redirect: in.Spec.Redirect.toConsul(), + Failover: in.Spec.Failover.toConsul(), + ConnectTimeout: in.Spec.ConnectTimeout, + } +} + +func (in *ServiceResolver) MatchesConsul(candidate capi.ConfigEntry) bool { + serviceResolverCandidate, ok := candidate.(*capi.ServiceResolverConfigEntry) + if !ok { + return false + } + + return in.Name() == serviceResolverCandidate.Name && + in.Spec.DefaultSubset == serviceResolverCandidate.DefaultSubset && + in.Spec.Subsets.matchesConsul(serviceResolverCandidate.Subsets) && + in.Spec.Redirect.matchesConsul(serviceResolverCandidate.Redirect) && + in.Spec.Failover.matchesConsul(serviceResolverCandidate.Failover) && + in.Spec.ConnectTimeout == serviceResolverCandidate.ConnectTimeout +} + +func (in *ServiceResolver) Validate() error { + var errs field.ErrorList + + // Iterate through failover map keys in sorted order so tests are + // deterministic. + keys := make([]string, 0, len(in.Spec.Failover)) + for k := range in.Spec.Failover { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + f := in.Spec.Failover[k] + if err := f.validate(k); err != nil { + errs = append(errs, err) + } + } + + return apierrors.NewInvalid( + schema.GroupKind{Group: ConsulHashicorpGroup, Kind: ServiceResolverKubeKind}, + in.Name(), errs) +} + +func (in *ServiceResolverFailover) validate(key string) *field.Error { + if in.Service == "" && in.ServiceSubset == "" && in.Namespace == "" && len(in.Datacenters) == 0 { + path := field.NewPath("spec").Child(fmt.Sprintf("failover[%s]", key)) + // NOTE: We're passing "{}" here as our value because we know that the + // error is we have an empty object. + return field.Invalid(path, "{}", + "service, serviceSubset, namespace and datacenters cannot all be empty at once") + } + return nil +} + +// +kubebuilder:object:root=true + +// ServiceResolverList contains a list of ServiceResolver +type ServiceResolverList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ServiceResolver `json:"items"` +} + +// ServiceResolverSpec defines the desired state of ServiceResolver +type ServiceResolverSpec struct { + // DefaultSubset is the subset to use when no explicit subset is requested. + // If empty the unnamed subset is used. + DefaultSubset string `json:"defaultSubset,omitempty"` + // Subsets is map of subset name to subset definition for all usable named + // subsets of this service. The map key is the name of the subset and all + // names must be valid DNS subdomain elements. + // This may be empty, in which case only the unnamed default subset will + // be usable. + Subsets ServiceResolverSubsetMap `json:"subsets,omitempty"` + // Redirect when configured, all attempts to resolve the service this + // resolver defines will be substituted for the supplied redirect + // EXCEPT when the redirect has already been applied. + // When substituting the supplied redirect, all other fields besides + // Kind, Name, and Redirect will be ignored. + Redirect *ServiceResolverRedirect `json:"redirect,omitempty"` + // Failover controls when and how to reroute traffic to an alternate pool of + // service instances. + // The map is keyed by the service subset it applies to and the special + // string "*" is a wildcard that applies to any subset not otherwise + // specified here. + Failover ServiceResolverFailoverMap `json:"failover,omitempty"` + // ConnectTimeout is the timeout for establishing new network connections + // to this service. + ConnectTimeout time.Duration `json:"connectTimeout,omitempty"` +} + +type ServiceResolverRedirect struct { + // Service is a service to resolve instead of the current service. + Service string `json:"service,omitempty"` + // ServiceSubset is a named subset of the given service to resolve instead + // of one defined as that service's DefaultSubset If empty the default + // subset is used. + ServiceSubset string `json:"serviceSubset,omitempty"` + // Namespace is the namespace to resolve the service from instead of the + // current one. + Namespace string `json:"namespace,omitempty"` + // Datacenter is the datacenter to resolve the service from instead of the + // current one. + Datacenter string `json:"datacenter,omitempty"` +} + +type ServiceResolverSubsetMap map[string]ServiceResolverSubset + +type ServiceResolverFailoverMap map[string]ServiceResolverFailover + +type ServiceResolverSubset struct { + // Filter is the filter expression to be used for selecting instances of the + // requested service. If empty all healthy instances are returned. This + // expression can filter on the same selectors as the Health API endpoint. + Filter string `json:"filter,omitempty"` + // OnlyPassing specifies the behavior of the resolver's health check + // interpretation. If this is set to false, instances with checks in the + // passing as well as the warning states will be considered healthy. If this + // is set to true, only instances with checks in the passing state will be + // considered healthy. + OnlyPassing bool `json:"onlyPassing,omitempty"` +} + +type ServiceResolverFailover struct { + // Service is the service to resolve instead of the default as the failover + // group of instances during failover. + Service string `json:"service,omitempty"` + // ServiceSubset is the named subset of the requested service to resolve as + // the failover group of instances. If empty the default subset for the + // requested service is used. + ServiceSubset string `json:"serviceSubset,omitempty"` + // Namespace is the namespace to resolve the requested service from to form + // the failover group of instances. If empty the current namespace is used. + Namespace string `json:"namespaces,omitempty"` + // Datacenters is a fixed list of datacenters to try during failover. + Datacenters []string `json:"datacenters,omitempty"` +} + +func init() { + SchemeBuilder.Register(&ServiceResolver{}, &ServiceResolverList{}) +} + +func (in ServiceResolverSubsetMap) toConsul() map[string]capi.ServiceResolverSubset { + if in == nil { + return nil + } + m := make(map[string]capi.ServiceResolverSubset) + for k, v := range in { + m[k] = v.toConsul() + } + return m +} + +func (in ServiceResolverSubsetMap) matchesConsul(candidate map[string]capi.ServiceResolverSubset) bool { + if len(in) != len(candidate) { + return false + } + + for thisKey, thisVal := range in { + candidateVal, ok := candidate[thisKey] + if !ok { + return false + } + if !thisVal.matchesConsul(candidateVal) { + return false + } + } + return true +} + +func (in ServiceResolverSubset) toConsul() capi.ServiceResolverSubset { + return capi.ServiceResolverSubset{ + Filter: in.Filter, + OnlyPassing: in.OnlyPassing, + } +} + +func (in ServiceResolverSubset) matchesConsul(candidate capi.ServiceResolverSubset) bool { + return in.OnlyPassing == candidate.OnlyPassing && in.Filter == candidate.Filter +} + +func (in *ServiceResolverRedirect) toConsul() *capi.ServiceResolverRedirect { + if in == nil { + return nil + } + return &capi.ServiceResolverRedirect{ + Service: in.Service, + ServiceSubset: in.ServiceSubset, + Namespace: in.Namespace, + Datacenter: in.Datacenter, + } +} + +func (in *ServiceResolverRedirect) matchesConsul(candidate *capi.ServiceResolverRedirect) bool { + if in == nil || candidate == nil { + return in == nil && candidate == nil + } + return in.Service == candidate.Service && + in.ServiceSubset == candidate.ServiceSubset && + in.Namespace == candidate.Namespace && + in.Datacenter == candidate.Datacenter +} + +func (in ServiceResolverFailoverMap) toConsul() map[string]capi.ServiceResolverFailover { + if in == nil { + return nil + } + m := make(map[string]capi.ServiceResolverFailover) + for k, v := range in { + m[k] = v.toConsul() + } + return m +} + +func (in ServiceResolverFailoverMap) matchesConsul(candidate map[string]capi.ServiceResolverFailover) bool { + if len(in) != len(candidate) { + return false + } + + for thisKey, thisVal := range in { + candidateVal, ok := candidate[thisKey] + if !ok { + return false + } + + if !thisVal.matchesConsul(candidateVal) { + return false + } + } + return true +} + +func (in ServiceResolverFailover) toConsul() capi.ServiceResolverFailover { + return capi.ServiceResolverFailover{ + Service: in.Service, + ServiceSubset: in.ServiceSubset, + Namespace: in.Namespace, + Datacenters: in.Datacenters, + } +} + +func (in ServiceResolverFailover) matchesConsul(candidate capi.ServiceResolverFailover) bool { + return in.Service == candidate.Service && + in.ServiceSubset == candidate.ServiceSubset && + in.Namespace == candidate.Namespace && + reflect.DeepEqual(in.Datacenters, candidate.Datacenters) +} diff --git a/api/v1alpha1/serviceresolver_types_test.go b/api/v1alpha1/serviceresolver_types_test.go new file mode 100644 index 0000000000..01d6561741 --- /dev/null +++ b/api/v1alpha1/serviceresolver_types_test.go @@ -0,0 +1,548 @@ +package v1alpha1 + +import ( + "testing" + "time" + + capi "github.com/hashicorp/consul/api" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Test MatchesConsul for cases that should return true. +func TestServiceResolver_MatchesConsulTrue(t *testing.T) { + cases := map[string]struct { + Ours ServiceResolver + Theirs *capi.ServiceResolverConfigEntry + }{ + "empty fields": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{}, + }, + Theirs: &capi.ServiceResolverConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + }, + }, + "all fields set": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{ + DefaultSubset: "default_subset", + Subsets: map[string]ServiceResolverSubset{ + "subset1": { + Filter: "filter1", + OnlyPassing: true, + }, + "subset2": { + Filter: "filter2", + OnlyPassing: false, + }, + }, + Redirect: &ServiceResolverRedirect{ + Service: "redirect", + ServiceSubset: "redirect_subset", + Namespace: "redirect_namespace", + Datacenter: "redirect_datacenter", + }, + Failover: map[string]ServiceResolverFailover{ + "failover1": { + Service: "failover1", + ServiceSubset: "failover_subset1", + Namespace: "failover_namespace1", + Datacenters: []string{"failover1_dc1", "failover1_dc2"}, + }, + "failover2": { + Service: "failover2", + ServiceSubset: "failover_subset2", + Namespace: "failover_namespace2", + Datacenters: []string{"failover2_dc1", "failover2_dc2"}, + }, + }, + ConnectTimeout: 1 * time.Second, + }, + }, + Theirs: &capi.ServiceResolverConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + DefaultSubset: "default_subset", + Subsets: map[string]capi.ServiceResolverSubset{ + "subset1": { + Filter: "filter1", + OnlyPassing: true, + }, + "subset2": { + Filter: "filter2", + OnlyPassing: false, + }, + }, + Redirect: &capi.ServiceResolverRedirect{ + Service: "redirect", + ServiceSubset: "redirect_subset", + Namespace: "redirect_namespace", + Datacenter: "redirect_datacenter", + }, + Failover: map[string]capi.ServiceResolverFailover{ + "failover1": { + Service: "failover1", + ServiceSubset: "failover_subset1", + Namespace: "failover_namespace1", + Datacenters: []string{"failover1_dc1", "failover1_dc2"}, + }, + "failover2": { + Service: "failover2", + ServiceSubset: "failover_subset2", + Namespace: "failover_namespace2", + Datacenters: []string{"failover2_dc1", "failover2_dc2"}, + }, + }, + ConnectTimeout: 1 * time.Second, + }, + }, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + require.True(t, c.Ours.MatchesConsul(c.Theirs)) + }) + } +} + +// Test MatchesConsul for cases that should return false. +func TestServiceResolver_MatchesConsulFalse(t *testing.T) { + cases := map[string]struct { + Ours ServiceResolver + Theirs capi.ConfigEntry + }{ + "different type": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{}, + }, + Theirs: &capi.ServiceConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + }, + }, + "different name": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{}, + }, + Theirs: &capi.ServiceResolverConfigEntry{ + Name: "other_name", + Kind: capi.ServiceResolver, + }, + }, + "different default subset": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{ + DefaultSubset: "default", + }, + }, + Theirs: &capi.ServiceResolverConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + DefaultSubset: "different", + }, + }, + "our subsets nil": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + + Spec: ServiceResolverSpec{ + Subsets: nil, + }, + }, + Theirs: &capi.ServiceResolverConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + Subsets: map[string]capi.ServiceResolverSubset{ + "sub": {}, + }, + }, + }, + "their subsets nil": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{ + Subsets: map[string]ServiceResolverSubset{ + "sub": {}, + }, + }, + }, + Theirs: &capi.ServiceResolverConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + Subsets: nil, + }, + }, + "different subsets contents": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{ + Subsets: map[string]ServiceResolverSubset{ + "sub": { + Filter: "filter", + }, + }, + }, + }, + Theirs: &capi.ServiceResolverConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + Subsets: map[string]capi.ServiceResolverSubset{ + "sub": { + Filter: "different_filter", + }, + }, + }, + }, + "our redirect nil": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{ + Redirect: nil, + }, + }, + Theirs: &capi.ServiceResolverConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + Redirect: &capi.ServiceResolverRedirect{ + Service: "service", + }, + }, + }, + "their redirect nil": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{ + Redirect: &ServiceResolverRedirect{ + Service: "service", + }, + }, + }, + Theirs: &capi.ServiceResolverConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + Redirect: nil, + }, + }, + "different redirect contents": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{ + Redirect: &ServiceResolverRedirect{ + Service: "service", + }, + }, + }, + Theirs: &capi.ServiceResolverConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + Redirect: &capi.ServiceResolverRedirect{ + Service: "different_service", + }, + }, + }, + "our failover nil": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{ + Failover: nil, + }, + }, + Theirs: &capi.ServiceResolverConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + Failover: map[string]capi.ServiceResolverFailover{ + "failover": {}, + }, + }, + }, + "their failover nil": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{ + Failover: map[string]ServiceResolverFailover{ + "failover": {}, + }, + }, + }, + Theirs: &capi.ServiceResolverConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + Failover: nil, + }, + }, + "different failover contents": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{ + Failover: map[string]ServiceResolverFailover{ + "failover": { + Service: "service", + }, + }, + }, + }, + Theirs: &capi.ServiceResolverConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + Failover: map[string]capi.ServiceResolverFailover{ + "failover": { + Service: "different_service", + }, + }, + }, + }, + "different connect timeout": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{ + ConnectTimeout: 1 * time.Second, + }, + }, + Theirs: &capi.ServiceResolverConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + ConnectTimeout: 2 * time.Second, + }, + }, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + require.False(t, c.Ours.MatchesConsul(c.Theirs)) + }) + } +} + +func TestServiceResolver_ToConsul(t *testing.T) { + cases := map[string]struct { + Ours ServiceResolver + Exp *capi.ServiceResolverConfigEntry + }{ + "empty fields": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{}, + }, + Exp: &capi.ServiceResolverConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + }, + }, + "every field set": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{ + DefaultSubset: "default_subset", + Subsets: map[string]ServiceResolverSubset{ + "subset1": { + Filter: "filter1", + OnlyPassing: true, + }, + "subset2": { + Filter: "filter2", + OnlyPassing: false, + }, + }, + Redirect: &ServiceResolverRedirect{ + Service: "redirect", + ServiceSubset: "redirect_subset", + Namespace: "redirect_namespace", + Datacenter: "redirect_datacenter", + }, + Failover: map[string]ServiceResolverFailover{ + "failover1": { + Service: "failover1", + ServiceSubset: "failover_subset1", + Namespace: "failover_namespace1", + Datacenters: []string{"failover1_dc1", "failover1_dc2"}, + }, + "failover2": { + Service: "failover2", + ServiceSubset: "failover_subset2", + Namespace: "failover_namespace2", + Datacenters: []string{"failover2_dc1", "failover2_dc2"}, + }, + }, + ConnectTimeout: 1 * time.Second, + }, + }, + Exp: &capi.ServiceResolverConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + DefaultSubset: "default_subset", + Subsets: map[string]capi.ServiceResolverSubset{ + "subset1": { + Filter: "filter1", + OnlyPassing: true, + }, + "subset2": { + Filter: "filter2", + OnlyPassing: false, + }, + }, + Redirect: &capi.ServiceResolverRedirect{ + Service: "redirect", + ServiceSubset: "redirect_subset", + Namespace: "redirect_namespace", + Datacenter: "redirect_datacenter", + }, + Failover: map[string]capi.ServiceResolverFailover{ + "failover1": { + Service: "failover1", + ServiceSubset: "failover_subset1", + Namespace: "failover_namespace1", + Datacenters: []string{"failover1_dc1", "failover1_dc2"}, + }, + "failover2": { + Service: "failover2", + ServiceSubset: "failover_subset2", + Namespace: "failover_namespace2", + Datacenters: []string{"failover2_dc1", "failover2_dc2"}, + }, + }, + ConnectTimeout: 1 * time.Second, + }, + }, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + act := c.Ours.ToConsul() + serviceResolver, ok := act.(*capi.ServiceResolverConfigEntry) + require.True(t, ok, "could not cast") + require.Equal(t, c.Exp, serviceResolver) + }) + } +} + +func TestServiceResolver_AddFinalizer(t *testing.T) { + serviceResolver := &ServiceResolver{} + serviceResolver.AddFinalizer("finalizer") + require.Equal(t, []string{"finalizer"}, serviceResolver.ObjectMeta.Finalizers) +} + +func TestServiceResolver_RemoveFinalizer(t *testing.T) { + serviceResolver := &ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Finalizers: []string{"f1", "f2"}, + }, + } + serviceResolver.RemoveFinalizer("f1") + require.Equal(t, []string{"f2"}, serviceResolver.ObjectMeta.Finalizers) +} + +func TestServiceResolver_SetSyncedCondition(t *testing.T) { + serviceResolver := &ServiceResolver{} + serviceResolver.SetSyncedCondition(corev1.ConditionTrue, "reason", "message") + + require.Equal(t, corev1.ConditionTrue, serviceResolver.Status.Conditions[0].Status) + require.Equal(t, "reason", serviceResolver.Status.Conditions[0].Reason) + require.Equal(t, "message", serviceResolver.Status.Conditions[0].Message) + now := metav1.Now() + require.True(t, serviceResolver.Status.Conditions[0].LastTransitionTime.Before(&now)) +} + +func TestServiceResolver_GetSyncedConditionStatus(t *testing.T) { + cases := []corev1.ConditionStatus{ + corev1.ConditionUnknown, + corev1.ConditionFalse, + corev1.ConditionTrue, + } + for _, status := range cases { + t.Run(string(status), func(t *testing.T) { + serviceResolver := &ServiceResolver{ + Status: Status{ + Conditions: []Condition{{ + Type: ConditionSynced, + Status: status, + }}, + }, + } + + require.Equal(t, status, serviceResolver.SyncedConditionStatus()) + }) + } +} + +// Test that if status is empty then GetCondition returns nil. +func TestServiceResolver_GetConditionWhenNil(t *testing.T) { + serviceResolver := &ServiceResolver{} + require.Nil(t, serviceResolver.GetCondition(ConditionSynced)) +} + +func TestServiceResolver_Validate(t *testing.T) { + cases := map[string]struct { + input *ServiceResolver + expectedErrMsg string + }{ + "failover service, servicesubset, namespace, datacenters empty": { + input: &ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: ServiceResolverSpec{ + Failover: map[string]ServiceResolverFailover{ + "failA": { + Service: "", + ServiceSubset: "", + Namespace: "", + Datacenters: nil, + }, + "failB": { + Service: "", + ServiceSubset: "", + Namespace: "", + Datacenters: nil, + }, + }, + }, + }, + expectedErrMsg: "serviceresolver.consul.hashicorp.com \"foo\" is invalid: [spec.failover[failA]: Invalid value: \"{}\": service, serviceSubset, namespace and datacenters cannot all be empty at once, spec.failover[failB]: Invalid value: \"{}\": service, serviceSubset, namespace and datacenters cannot all be empty at once]", + }, + } + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + err := testCase.input.Validate() + require.EqualError(t, err, testCase.expectedErrMsg) + }) + } +} diff --git a/api/v1alpha1/serviceresolver_webhook.go b/api/v1alpha1/serviceresolver_webhook.go new file mode 100644 index 0000000000..a36c7e72d4 --- /dev/null +++ b/api/v1alpha1/serviceresolver_webhook.go @@ -0,0 +1,67 @@ +package v1alpha1 + +import ( + "context" + "net/http" + + "github.com/go-logr/logr" + "github.com/hashicorp/consul-k8s/api/common" + capi "github.com/hashicorp/consul/api" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +func NewServiceResolverValidator(client client.Client, consulClient *capi.Client, logger logr.Logger) *serviceResolverValidator { + return &serviceResolverValidator{ + Client: client, + ConsulClient: consulClient, + Logger: logger, + } +} + +type serviceResolverValidator struct { + client.Client + ConsulClient *capi.Client + Logger logr.Logger + decoder *admission.Decoder +} + +// NOTE: The path value in the below line is the path to the webhook. +// If it is updated, run code-gen, update subcommand/controller/command.go +// and the consul-helm value for the path to the webhook. +// +// NOTE: The below line cannot be combined with any other comment. If it is +// it will break the code generation. +// +// +kubebuilder:webhook:verbs=create;update,path=/mutate-v1alpha1-serviceresolver,mutating=true,failurePolicy=fail,groups=consul.hashicorp.com,resources=serviceresolvers,versions=v1alpha1,name=mutate-serviceresolver.consul.hashicorp.com + +func (v *serviceResolverValidator) Handle(ctx context.Context, req admission.Request) admission.Response { + var svcResolver ServiceResolver + err := v.decoder.Decode(req, &svcResolver) + if err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + return common.ValidateConfigEntry(ctx, + req, + v.Logger, + v, + &svcResolver) +} + +func (v *serviceResolverValidator) List(ctx context.Context) ([]common.ConfigEntryResource, error) { + var svcResolverList ServiceResolverList + if err := v.Client.List(ctx, &svcResolverList); err != nil { + return nil, err + } + var entries []common.ConfigEntryResource + for _, item := range svcResolverList.Items { + entries = append(entries, common.ConfigEntryResource(&item)) + } + return entries, nil +} + +func (v *serviceResolverValidator) InjectDecoder(d *admission.Decoder) error { + v.decoder = d + return nil +} diff --git a/api/v1alpha1/status.go b/api/v1alpha1/status.go index 7f06f57b4d..3aade7a5ad 100644 --- a/api/v1alpha1/status.go +++ b/api/v1alpha1/status.go @@ -66,9 +66,6 @@ func (c *Condition) IsUnknown() bool { return c.Status == corev1.ConditionUnknown } -// Status shows how we expect folks to embed Conditions in -// their Status field. -// WARNING: Adding fields to this struct will add them to all Consul-k8s resources. // +k8s:deepcopy-gen=true // +k8s:openapi-gen=true type Status struct { diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index c51eab763c..841693d6ce 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -172,21 +172,190 @@ func (in *ServiceDefaultsSpec) DeepCopy() *ServiceDefaultsSpec { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ServiceDefaultsStatus) DeepCopyInto(out *ServiceDefaultsStatus) { +func (in *ServiceResolver) DeepCopyInto(out *ServiceResolver) { *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 ServiceDefaultsStatus. -func (in *ServiceDefaultsStatus) DeepCopy() *ServiceDefaultsStatus { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceResolver. +func (in *ServiceResolver) DeepCopy() *ServiceResolver { + if in == nil { + return nil + } + out := new(ServiceResolver) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceResolver) 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 *ServiceResolverFailover) DeepCopyInto(out *ServiceResolverFailover) { + *out = *in + if in.Datacenters != nil { + in, out := &in.Datacenters, &out.Datacenters + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceResolverFailover. +func (in *ServiceResolverFailover) DeepCopy() *ServiceResolverFailover { + if in == nil { + return nil + } + out := new(ServiceResolverFailover) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in ServiceResolverFailoverMap) DeepCopyInto(out *ServiceResolverFailoverMap) { + { + in := &in + *out = make(ServiceResolverFailoverMap, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceResolverFailoverMap. +func (in ServiceResolverFailoverMap) DeepCopy() ServiceResolverFailoverMap { if in == nil { return nil } - out := new(ServiceDefaultsStatus) + out := new(ServiceResolverFailoverMap) + in.DeepCopyInto(out) + return *out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceResolverList) DeepCopyInto(out *ServiceResolverList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ServiceResolver, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceResolverList. +func (in *ServiceResolverList) DeepCopy() *ServiceResolverList { + if in == nil { + return nil + } + out := new(ServiceResolverList) in.DeepCopyInto(out) return out } +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceResolverList) 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 *ServiceResolverRedirect) DeepCopyInto(out *ServiceResolverRedirect) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceResolverRedirect. +func (in *ServiceResolverRedirect) DeepCopy() *ServiceResolverRedirect { + if in == nil { + return nil + } + out := new(ServiceResolverRedirect) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceResolverSpec) DeepCopyInto(out *ServiceResolverSpec) { + *out = *in + if in.Subsets != nil { + in, out := &in.Subsets, &out.Subsets + *out = make(ServiceResolverSubsetMap, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Redirect != nil { + in, out := &in.Redirect, &out.Redirect + *out = new(ServiceResolverRedirect) + **out = **in + } + if in.Failover != nil { + in, out := &in.Failover, &out.Failover + *out = make(ServiceResolverFailoverMap, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceResolverSpec. +func (in *ServiceResolverSpec) DeepCopy() *ServiceResolverSpec { + if in == nil { + return nil + } + out := new(ServiceResolverSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceResolverSubset) DeepCopyInto(out *ServiceResolverSubset) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceResolverSubset. +func (in *ServiceResolverSubset) DeepCopy() *ServiceResolverSubset { + if in == nil { + return nil + } + out := new(ServiceResolverSubset) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in ServiceResolverSubsetMap) DeepCopyInto(out *ServiceResolverSubsetMap) { + { + in := &in + *out = make(ServiceResolverSubsetMap, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceResolverSubsetMap. +func (in ServiceResolverSubsetMap) DeepCopy() ServiceResolverSubsetMap { + if in == nil { + return nil + } + out := new(ServiceResolverSubsetMap) + in.DeepCopyInto(out) + return *out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Status) DeepCopyInto(out *Status) { *out = *in diff --git a/config/crd/bases/consul.hashicorp.com_servicedefaults.yaml b/config/crd/bases/consul.hashicorp.com_servicedefaults.yaml index 675a345fbe..199ea3e69d 100644 --- a/config/crd/bases/consul.hashicorp.com_servicedefaults.yaml +++ b/config/crd/bases/consul.hashicorp.com_servicedefaults.yaml @@ -95,7 +95,6 @@ spec: type: string type: object status: - description: ServiceDefaultsStatus defines the observed state of ServiceDefaults properties: conditions: description: Conditions indicate the latest available observations of diff --git a/config/crd/bases/consul.hashicorp.com_serviceresolvers.yaml b/config/crd/bases/consul.hashicorp.com_serviceresolvers.yaml new file mode 100644 index 0000000000..a1a47d3cb9 --- /dev/null +++ b/config/crd/bases/consul.hashicorp.com_serviceresolvers.yaml @@ -0,0 +1,175 @@ + +--- +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.2.5 + creationTimestamp: null + name: serviceresolvers.consul.hashicorp.com +spec: + additionalPrinterColumns: + - JSONPath: .status.conditions[?(@.type=="Synced")].status + description: The sync status of the resource with Consul + name: Synced + type: string + group: consul.hashicorp.com + names: + kind: ServiceResolver + listKind: ServiceResolverList + plural: serviceresolvers + singular: serviceresolver + scope: Namespaced + subresources: + status: {} + validation: + openAPIV3Schema: + description: ServiceResolver is the Schema for the serviceresolvers API + 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: ServiceResolverSpec defines the desired state of ServiceResolver + properties: + connectTimeout: + description: ConnectTimeout is the timeout for establishing new network + connections to this service. + format: int64 + type: integer + defaultSubset: + description: DefaultSubset is the subset to use when no explicit subset + is requested. If empty the unnamed subset is used. + type: string + failover: + additionalProperties: + properties: + datacenters: + description: Datacenters is a fixed list of datacenters to try + during failover. + items: + type: string + type: array + namespaces: + description: Namespace is the namespace to resolve the requested + service from to form the failover group of instances. If empty + the current namespace is used. + type: string + service: + description: Service is the service to resolve instead of the + default as the failover group of instances during failover. + type: string + serviceSubset: + description: ServiceSubset is the named subset of the requested + service to resolve as the failover group of instances. If empty + the default subset for the requested service is used. + type: string + type: object + description: Failover controls when and how to reroute traffic to an + alternate pool of service instances. The map is keyed by the service + subset it applies to and the special string "*" is a wildcard that + applies to any subset not otherwise specified here. + type: object + redirect: + description: Redirect when configured, all attempts to resolve the service + this resolver defines will be substituted for the supplied redirect + EXCEPT when the redirect has already been applied. When substituting + the supplied redirect, all other fields besides Kind, Name, and Redirect + will be ignored. + properties: + datacenter: + description: Datacenter is the datacenter to resolve the service + from instead of the current one. + type: string + namespace: + description: Namespace is the namespace to resolve the service from + instead of the current one. + type: string + service: + description: Service is a service to resolve instead of the current + service. + type: string + serviceSubset: + description: ServiceSubset is a named subset of the given service + to resolve instead of one defined as that service's DefaultSubset + If empty the default subset is used. + type: string + type: object + subsets: + additionalProperties: + properties: + filter: + description: Filter is the filter expression to be used for selecting + instances of the requested service. If empty all healthy instances + are returned. This expression can filter on the same selectors + as the Health API endpoint. + type: string + onlyPassing: + description: OnlyPassing specifies the behavior of the resolver's + health check interpretation. If this is set to false, instances + with checks in the passing as well as the warning states will + be considered healthy. If this is set to true, only instances + with checks in the passing state will be considered healthy. + type: boolean + type: object + description: Subsets is map of subset name to subset definition for + all usable named subsets of this service. The map key is the name + of the subset and all names must be valid DNS subdomain elements. + This may be empty, in which case only the unnamed default subset will + be usable. + type: object + type: object + status: + properties: + conditions: + description: Conditions indicate the latest available observations of + a resource's current state. + items: + description: 'Conditions define a readiness condition for a Consul + resource. See: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties' + properties: + lastTransitionTime: + description: LastTransitionTime is the last time the condition + transitioned from one status to another. + format: date-time + type: string + message: + description: A human readable message indicating details about + the transition. + type: string + reason: + description: The reason for the condition's last transition. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: Type of condition. + type: string + required: + - status + - type + type: object + type: array + type: object + type: object + version: v1alpha1 + versions: + - name: v1alpha1 + served: true + storage: true +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 6ee500b452..13312a5cc6 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -3,17 +3,20 @@ # It should be run by config/default resources: - bases/consul.hashicorp.com_servicedefaults.yaml +- bases/consul.hashicorp.com_serviceresolvers.yaml # +kubebuilder:scaffold:crdkustomizeresource patchesStrategicMerge: # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. # patches here are for enabling the conversion webhook for each CRD - patches/webhook_in_servicedefaults.yaml +- patches/webhook_in_serviceresolvers.yaml # +kubebuilder:scaffold:crdkustomizewebhookpatch # [CERTMANAGER] To enable webhook, uncomment all the sections with [CERTMANAGER] prefix. # patches here are for enabling the CA injection for each CRD -- patches/cainjection_in_servicedefaults.yaml +#- patches/cainjection_in_servicedefaults.yaml +#- patches/cainjection_in_serviceresolvers.yaml # +kubebuilder:scaffold:crdkustomizecainjectionpatch # the following config is for teaching kustomize how to do kustomization for CRDs. diff --git a/config/crd/patches/cainjection_in_serviceresolvers.yaml b/config/crd/patches/cainjection_in_serviceresolvers.yaml new file mode 100644 index 0000000000..39a09eb8e1 --- /dev/null +++ b/config/crd/patches/cainjection_in_serviceresolvers.yaml @@ -0,0 +1,8 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +# CRD conversion requires k8s 1.13 or later. +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) + name: serviceresolvers.consul.hashicorp.com diff --git a/config/crd/patches/webhook_in_servicedefaults.yaml b/config/crd/patches/webhook_in_servicedefaults.yaml index 9b7609abb8..774953f50f 100644 --- a/config/crd/patches/webhook_in_servicedefaults.yaml +++ b/config/crd/patches/webhook_in_servicedefaults.yaml @@ -6,14 +6,12 @@ metadata: name: servicedefaults.consul.hashicorp.com spec: conversion: - # todo: this wasn't working with my kube version - strategy: None -# strategy: Webhook -# webhookClientConfig: -# # this is "\n" used as a placeholder, otherwise it will be rejected by the apiserver for being blank, -# # but we're going to set it later using the cert-manager (or potentially a patch if not using cert-manager) -# caBundle: Cg== -# service: -# namespace: system -# name: webhook-service -# path: /convert + strategy: Webhook + webhookClientConfig: + # this is "\n" used as a placeholder, otherwise it will be rejected by the apiserver for being blank, + # but we're going to set it later using the cert-manager (or potentially a patch if not using cert-manager) + caBundle: Cg== + service: + namespace: system + name: webhook-service + path: /convert diff --git a/config/crd/patches/webhook_in_serviceresolvers.yaml b/config/crd/patches/webhook_in_serviceresolvers.yaml new file mode 100644 index 0000000000..b9f9b42a21 --- /dev/null +++ b/config/crd/patches/webhook_in_serviceresolvers.yaml @@ -0,0 +1,17 @@ +# The following patch enables conversion webhook for CRD +# CRD conversion requires k8s 1.13 or later. +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: serviceresolvers.consul.hashicorp.com +spec: + conversion: + strategy: Webhook + webhookClientConfig: + # this is "\n" used as a placeholder, otherwise it will be rejected by the apiserver for being blank, + # but we're going to set it later using the cert-manager (or potentially a patch if not using cert-manager) + caBundle: Cg== + service: + namespace: system + name: webhook-service + path: /convert diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 036cbf8d46..5e0fe4815b 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -26,3 +26,23 @@ rules: - get - patch - update +- apiGroups: + - consul.hashicorp.com + resources: + - serviceresolvers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - serviceresolvers/status + verbs: + - get + - patch + - update diff --git a/config/rbac/serviceresolver_editor_role.yaml b/config/rbac/serviceresolver_editor_role.yaml new file mode 100644 index 0000000000..5baff84934 --- /dev/null +++ b/config/rbac/serviceresolver_editor_role.yaml @@ -0,0 +1,24 @@ +# permissions for end users to edit serviceresolvers. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: serviceresolver-editor-role +rules: +- apiGroups: + - consul.hashicorp.com + resources: + - serviceresolvers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - serviceresolvers/status + verbs: + - get diff --git a/config/rbac/serviceresolver_viewer_role.yaml b/config/rbac/serviceresolver_viewer_role.yaml new file mode 100644 index 0000000000..ca990258fb --- /dev/null +++ b/config/rbac/serviceresolver_viewer_role.yaml @@ -0,0 +1,20 @@ +# permissions for end users to view serviceresolvers. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: serviceresolver-viewer-role +rules: +- apiGroups: + - consul.hashicorp.com + resources: + - serviceresolvers + verbs: + - get + - list + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - serviceresolvers/status + verbs: + - get diff --git a/config/samples/consul_v1alpha1_serviceresolver.yaml b/config/samples/consul_v1alpha1_serviceresolver.yaml new file mode 100644 index 0000000000..f1a2f29c8b --- /dev/null +++ b/config/samples/consul_v1alpha1_serviceresolver.yaml @@ -0,0 +1,7 @@ +apiVersion: consul.hashicorp.com/v1alpha1 +kind: ServiceResolver +metadata: + name: serviceresolver-sample +spec: + # Add fields here + foo: bar diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index b4139c5eb9..ce825ae42d 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -24,3 +24,21 @@ webhooks: - UPDATE resources: - servicedefaults +- clientConfig: + caBundle: Cg== + service: + name: webhook-service + namespace: system + path: /mutate-v1alpha1-serviceresolver + failurePolicy: Fail + name: mutate-serviceresolver.consul.hashicorp.com + rules: + - apiGroups: + - consul.hashicorp.com + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - serviceresolvers diff --git a/controllers/configentry_controller.go b/controllers/configentry_controller.go new file mode 100644 index 0000000000..8eeceefaea --- /dev/null +++ b/controllers/configentry_controller.go @@ -0,0 +1,234 @@ +package controllers + +import ( + "context" + "fmt" + "strings" + + "github.com/go-logr/logr" + "github.com/hashicorp/consul-k8s/api/common" + "github.com/hashicorp/consul-k8s/namespaces" + capi "github.com/hashicorp/consul/api" + corev1 "k8s.io/api/core/v1" + k8serr "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + FinalizerName = "finalizers.consul.hashicorp.com" + ConsulAgentError = "ConsulAgentError" +) + +// Controller is implemented by CRD-specific controllers. It is used by +// ConfigEntryController to abstract CRD-specific controllers. +type Controller interface { + // Update updates the state of the whole object. + Update(context.Context, runtime.Object, ...client.UpdateOption) error + // UpdateStatus updates the state of just the object's status. + UpdateStatus(context.Context, runtime.Object, ...client.UpdateOption) error + // Get retrieves an obj for the given object key from the Kubernetes Cluster. + // obj must be a struct pointer so that obj can be updated with the response + // returned by the Server. + Get(ctx context.Context, key client.ObjectKey, obj runtime.Object) error + // Logger returns a logger with values added for the specific controller + // and request name. + Logger(types.NamespacedName) logr.Logger +} + +// ConfigEntryController is a generic controller that is used to reconcile +// all config entry types, e.g. ServiceDefaults, ServiceResolver, etc, since +// they share the same reconcile behaviour. +type ConfigEntryController struct { + ConsulClient *capi.Client + + // EnableConsulNamespaces indicates that a user is running Consul Enterprise + // with version 1.7+ which supports namespaces. + EnableConsulNamespaces bool + + // ConsulDestinationNamespace is the name of the Consul namespace to create + // all config entries in. If EnableNSMirroring is true this is ignored. + ConsulDestinationNamespace string + + // EnableNSMirroring causes Consul namespaces to be created to match the + // k8s namespace of any config entry custom resource. Config entries will + // be created in the matching Consul namespace. + EnableNSMirroring bool + + // NSMirroringPrefix is an optional prefix that can be added to the Consul + // namespaces created while mirroring. For example, if it is set to "k8s-", + // then the k8s `default` namespace will be mirrored in Consul's + // `k8s-default` namespace. + NSMirroringPrefix string + + // CrossNSACLPolicy is the name of the ACL policy to attach to + // any created Consul namespaces to allow cross namespace service discovery. + // Only necessary if ACLs are enabled. + CrossNSACLPolicy string +} + +// ReconcileEntry reconciles an update to a resource. CRD-specific controller's +// call this function because it handles reconciliation of config entries +// generically. +// CRD-specific controller should pass themselves in as updater since we +// need to call back into their own update methods to ensure they update their +// internal state. +func (r *ConfigEntryController) ReconcileEntry( + crdCtrl Controller, + req ctrl.Request, + configEntry common.ConfigEntryResource) (ctrl.Result, error) { + + ctx := context.Background() + logger := crdCtrl.Logger(req.NamespacedName) + + err := crdCtrl.Get(ctx, req.NamespacedName, configEntry) + if k8serr.IsNotFound(err) { + return ctrl.Result{}, client.IgnoreNotFound(err) + } else if err != nil { + logger.Error(err, "retrieving resource") + return ctrl.Result{}, err + } + + if configEntry.GetObjectMeta().DeletionTimestamp.IsZero() { + // The object is not being deleted, so if it does not have our finalizer, + // then let's add the finalizer and update the object. This is equivalent + // registering our finalizer. + if !containsString(configEntry.GetObjectMeta().Finalizers, FinalizerName) { + configEntry.AddFinalizer(FinalizerName) + if err := r.syncUnknown(ctx, crdCtrl, configEntry); err != nil { + return ctrl.Result{}, err + } + } + } else { + // The object is being deleted + if containsString(configEntry.GetObjectMeta().Finalizers, FinalizerName) { + logger.Info("deletion event") + // Our finalizer is present, so we need to delete the config entry + // from consul. + _, err := r.ConsulClient.ConfigEntries().Delete(configEntry.ConsulKind(), configEntry.Name(), &capi.WriteOptions{ + Namespace: r.consulNamespace(req.Namespace), + }) + if err != nil { + return ctrl.Result{}, fmt.Errorf("deleting config entry from consul: %w", err) + } + logger.Info("deletion from Consul successful") + + // remove our finalizer from the list and update it. + configEntry.RemoveFinalizer(FinalizerName) + if err := crdCtrl.Update(ctx, configEntry); err != nil { + return ctrl.Result{}, err + } + logger.Info("finalizer removed") + } + + // Stop reconciliation as the item is being deleted + return ctrl.Result{}, nil + } + + // Check to see if consul has service defaults with the same name + entry, _, err := r.ConsulClient.ConfigEntries().Get(configEntry.ConsulKind(), configEntry.Name(), &capi.QueryOptions{ + Namespace: r.consulNamespace(req.Namespace), + }) + // If a config entry with this name does not exist + if isNotFoundErr(err) { + logger.Info("config entry not found in consul") + + // If Consul namespaces are enabled we may need to create the + // destination consul namespace first. + if r.EnableConsulNamespaces { + if err := namespaces.EnsureExists(r.ConsulClient, r.consulNamespace(req.Namespace), r.CrossNSACLPolicy); err != nil { + return r.syncFailed(ctx, logger, crdCtrl, configEntry, ConsulAgentError, + fmt.Errorf("creating consul namespace %q: %w", r.consulNamespace(req.Namespace), err)) + } + } + + // Create the config entry + _, _, err := r.ConsulClient.ConfigEntries().Set(configEntry.ToConsul(), &capi.WriteOptions{ + Namespace: r.consulNamespace(req.Namespace), + }) + if err != nil { + return r.syncFailed(ctx, logger, crdCtrl, configEntry, ConsulAgentError, + fmt.Errorf("writing config entry to consul: %w", err)) + } + return r.syncSuccessful(ctx, crdCtrl, configEntry) + } + + // If there is an error when trying to get the config entry from the api server, + // fail the reconcile. + if err != nil { + return r.syncFailed(ctx, logger, crdCtrl, configEntry, ConsulAgentError, err) + } + + if !configEntry.MatchesConsul(entry) { + _, _, err := r.ConsulClient.ConfigEntries().Set(configEntry.ToConsul(), &capi.WriteOptions{ + Namespace: r.consulNamespace(req.Namespace), + }) + if err != nil { + return r.syncUnknownWithError(ctx, logger, crdCtrl, configEntry, ConsulAgentError, + fmt.Errorf("updating config entry in consul: %w", err)) + } + return r.syncSuccessful(ctx, crdCtrl, configEntry) + } else if configEntry.SyncedConditionStatus() == corev1.ConditionTrue { + return r.syncSuccessful(ctx, crdCtrl, configEntry) + } + + return ctrl.Result{}, nil +} + +func (r *ConfigEntryController) consulNamespace(kubeNS string) string { + return namespaces.ConsulNamespace(kubeNS, r.EnableConsulNamespaces, r.ConsulDestinationNamespace, r.EnableNSMirroring, r.NSMirroringPrefix) +} + +func (r *ConfigEntryController) syncFailed(ctx context.Context, logger logr.Logger, updater Controller, configEntry common.ConfigEntryResource, errType string, err error) (ctrl.Result, error) { + configEntry.SetSyncedCondition(corev1.ConditionFalse, errType, err.Error()) + if updateErr := updater.UpdateStatus(ctx, configEntry); updateErr != nil { + // Log the original error here because we are returning the updateErr. + // Otherwise the original error would be lost. + logger.Error(err, "sync failed") + return ctrl.Result{}, updateErr + } + return ctrl.Result{}, err +} + +func (r *ConfigEntryController) syncSuccessful(ctx context.Context, updater Controller, configEntry common.ConfigEntryResource) (ctrl.Result, error) { + configEntry.SetSyncedCondition(corev1.ConditionTrue, "", "") + return ctrl.Result{}, updater.UpdateStatus(ctx, configEntry) +} + +func (r *ConfigEntryController) syncUnknown(ctx context.Context, updater Controller, configEntry common.ConfigEntryResource) error { + configEntry.SetSyncedCondition(corev1.ConditionUnknown, "", "") + return updater.Update(ctx, configEntry) +} + +func (r *ConfigEntryController) syncUnknownWithError(ctx context.Context, + logger logr.Logger, + updater Controller, + configEntry common.ConfigEntryResource, + errType string, + err error) (ctrl.Result, error) { + + configEntry.SetSyncedCondition(corev1.ConditionUnknown, errType, err.Error()) + if updateErr := updater.UpdateStatus(ctx, configEntry); updateErr != nil { + // Log the original error here because we are returning the updateErr. + // Otherwise the original error would be lost. + logger.Error(err, "sync status unknown") + return ctrl.Result{}, updateErr + } + return ctrl.Result{}, err +} + +func isNotFoundErr(err error) bool { + return err != nil && strings.Contains(err.Error(), "404") +} + +// containsString returns true if s is in slice. +func containsString(slice []string, s string) bool { + for _, item := range slice { + if item == s { + return true + } + } + return false +} diff --git a/controllers/servicedefaults_controller_ent_test.go b/controllers/configentry_controller_ent_test.go similarity index 82% rename from controllers/servicedefaults_controller_ent_test.go rename to controllers/configentry_controller_ent_test.go index 705c0ea5ef..347c02ba65 100644 --- a/controllers/servicedefaults_controller_ent_test.go +++ b/controllers/configentry_controller_ent_test.go @@ -14,13 +14,20 @@ import ( "github.com/hashicorp/consul/sdk/testutil" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" ) -func TestServiceDefaultsController_createsConfigEntry_consulNamespaces(tt *testing.T) { +// NOTE: We're not testing each controller type here because that's done in +// the OSS tests and it would result in too many permutations. Instead +// we're only testing with the ServiceDefaults controller which will exercise +// all the namespaces code. + +func TestConfigEntryController_createsConfigEntry_consulNamespaces(tt *testing.T) { + tt.Parallel() + cases := map[string]struct { Mirror bool MirrorPrefix string @@ -78,7 +85,7 @@ func TestServiceDefaultsController_createsConfigEntry_consulNamespaces(tt *testi Protocol: "http", }, } - s := scheme.Scheme + s := runtime.NewScheme() s.AddKnownTypes(v1alpha1.GroupVersion, svcDefaults) ctx := context.Background() @@ -92,15 +99,17 @@ func TestServiceDefaultsController_createsConfigEntry_consulNamespaces(tt *testi client := fake.NewFakeClientWithScheme(s, svcDefaults) - r := controllers.ServiceDefaultsReconciler{ - Client: client, - Log: logrtest.TestLogger{T: t}, - Scheme: s, - ConsulClient: consulClient, - EnableConsulNamespaces: true, - EnableNSMirroring: c.Mirror, - NSMirroringPrefix: c.MirrorPrefix, - ConsulDestinationNamespace: c.DestConsulNS, + r := controllers.ServiceDefaultsController{ + Client: client, + Log: logrtest.TestLogger{T: t}, + Scheme: s, + ConfigEntryController: &controllers.ConfigEntryController{ + ConsulClient: consulClient, + EnableConsulNamespaces: true, + EnableNSMirroring: c.Mirror, + NSMirroringPrefix: c.MirrorPrefix, + ConsulDestinationNamespace: c.DestConsulNS, + }, } resp, err := r.Reconcile(ctrl.Request{ @@ -123,7 +132,7 @@ func TestServiceDefaultsController_createsConfigEntry_consulNamespaces(tt *testi // Check that the status is "synced". err = client.Get(ctx, types.NamespacedName{ Namespace: svcDefaults.Namespace, - Name: svcDefaults.Name, + Name: svcDefaults.Name(), }, svcDefaults) req.NoError(err) conditionSynced := svcDefaults.Status.GetCondition(v1alpha1.ConditionSynced) @@ -133,7 +142,9 @@ func TestServiceDefaultsController_createsConfigEntry_consulNamespaces(tt *testi } } -func TestServiceDefaultsController_updatesConfigEntry_consulNamespaces(tt *testing.T) { +func TestConfigEntryController_updatesConfigEntry_consulNamespaces(tt *testing.T) { + tt.Parallel() + cases := map[string]struct { Mirror bool MirrorPrefix string @@ -192,7 +203,7 @@ func TestServiceDefaultsController_updatesConfigEntry_consulNamespaces(tt *testi Protocol: "http", }, } - s := scheme.Scheme + s := runtime.NewScheme() s.AddKnownTypes(v1alpha1.GroupVersion, svcDefaults) ctx := context.Background() @@ -206,15 +217,17 @@ func TestServiceDefaultsController_updatesConfigEntry_consulNamespaces(tt *testi client := fake.NewFakeClientWithScheme(s, svcDefaults) - r := controllers.ServiceDefaultsReconciler{ - Client: client, - Log: logrtest.TestLogger{T: t}, - Scheme: s, - ConsulClient: consulClient, - EnableConsulNamespaces: true, - ConsulDestinationNamespace: c.DestConsulNS, - EnableNSMirroring: c.Mirror, - NSMirroringPrefix: c.MirrorPrefix, + r := controllers.ServiceDefaultsController{ + Client: client, + Log: logrtest.TestLogger{T: t}, + Scheme: s, + ConfigEntryController: &controllers.ConfigEntryController{ + ConsulClient: consulClient, + EnableConsulNamespaces: true, + EnableNSMirroring: c.Mirror, + NSMirroringPrefix: c.MirrorPrefix, + ConsulDestinationNamespace: c.DestConsulNS, + }, } // We haven't run reconcile yet so ensure it's created in Consul. @@ -239,7 +252,7 @@ func TestServiceDefaultsController_updatesConfigEntry_consulNamespaces(tt *testi // First get it so we have the latest revision number. err = client.Get(ctx, types.NamespacedName{ Namespace: svcDefaults.Namespace, - Name: svcDefaults.Name, + Name: svcDefaults.Name(), }, svcDefaults) req.NoError(err) @@ -267,7 +280,9 @@ func TestServiceDefaultsController_updatesConfigEntry_consulNamespaces(tt *testi } } -func TestServiceDefaultsController_deletesConfigEntry_consulNamespaces(tt *testing.T) { +func TestConfigEntryController_deletesConfigEntry_consulNamespaces(tt *testing.T) { + tt.Parallel() + cases := map[string]struct { Mirror bool MirrorPrefix string @@ -329,7 +344,7 @@ func TestServiceDefaultsController_deletesConfigEntry_consulNamespaces(tt *testi Protocol: "http", }, } - s := scheme.Scheme + s := runtime.NewScheme() s.AddKnownTypes(v1alpha1.GroupVersion, svcDefaults) consul, err := testutil.NewTestServerConfigT(t, nil) @@ -342,15 +357,17 @@ func TestServiceDefaultsController_deletesConfigEntry_consulNamespaces(tt *testi client := fake.NewFakeClientWithScheme(s, svcDefaults) - r := controllers.ServiceDefaultsReconciler{ - Client: client, - Log: logrtest.TestLogger{T: t}, - Scheme: s, - ConsulClient: consulClient, - EnableConsulNamespaces: true, - ConsulDestinationNamespace: c.DestConsulNS, - EnableNSMirroring: c.Mirror, - NSMirroringPrefix: c.MirrorPrefix, + r := controllers.ServiceDefaultsController{ + Client: client, + Log: logrtest.TestLogger{T: t}, + Scheme: s, + ConfigEntryController: &controllers.ConfigEntryController{ + ConsulClient: consulClient, + EnableConsulNamespaces: true, + EnableNSMirroring: c.Mirror, + NSMirroringPrefix: c.MirrorPrefix, + ConsulDestinationNamespace: c.DestConsulNS, + }, } // We haven't run reconcile yet so ensure it's created in Consul. diff --git a/controllers/configentry_controller_test.go b/controllers/configentry_controller_test.go new file mode 100644 index 0000000000..1c38a94f8d --- /dev/null +++ b/controllers/configentry_controller_test.go @@ -0,0 +1,482 @@ +package controllers + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/go-logr/logr" + logrtest "github.com/go-logr/logr/testing" + "github.com/hashicorp/consul-k8s/api/common" + "github.com/hashicorp/consul-k8s/api/v1alpha1" + capi "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/sdk/testutil" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +type testReconciler interface { + Reconcile(req ctrl.Request) (ctrl.Result, error) +} + +func TestConfigEntryControllers_createsConfigEntry(t *testing.T) { + t.Parallel() + kubeNS := "default" + + cases := []struct { + kubeKind string + consulKind string + configEntryResource common.ConfigEntryResource + reconciler func(client.Client, *capi.Client, logr.Logger) testReconciler + compare func(t *testing.T, consul capi.ConfigEntry) + }{ + { + kubeKind: "ServiceDefaults", + consulKind: capi.ServiceDefaults, + configEntryResource: &v1alpha1.ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceDefaultsSpec{ + Protocol: "http", + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceDefaultsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + }, + } + }, + compare: func(t *testing.T, consulEntry capi.ConfigEntry) { + svcDefault, ok := consulEntry.(*capi.ServiceConfigEntry) + require.True(t, ok, "cast error") + require.Equal(t, "http", svcDefault.Protocol) + }, + }, + { + kubeKind: "ServiceResolver", + consulKind: capi.ServiceResolver, + configEntryResource: &v1alpha1.ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceResolverSpec{ + Redirect: &v1alpha1.ServiceResolverRedirect{ + Service: "redirect", + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceResolverController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + }, + } + }, + compare: func(t *testing.T, consulEntry capi.ConfigEntry) { + svcDefault, ok := consulEntry.(*capi.ServiceResolverConfigEntry) + require.True(t, ok, "cast error") + require.Equal(t, "redirect", svcDefault.Redirect.Service) + }, + }, + } + + for _, c := range cases { + t.Run(c.kubeKind, func(t *testing.T) { + req := require.New(t) + ctx := context.Background() + + s := runtime.NewScheme() + s.AddKnownTypes(v1alpha1.GroupVersion, c.configEntryResource) + client := fake.NewFakeClientWithScheme(s, c.configEntryResource) + + consul, err := testutil.NewTestServerConfigT(t, nil) + req.NoError(err) + defer consul.Stop() + consulClient, err := capi.NewClient(&capi.Config{ + Address: consul.HTTPAddr, + }) + req.NoError(err) + + r := c.reconciler(client, consulClient, logrtest.TestLogger{T: t}) + namespacedName := types.NamespacedName{ + Namespace: kubeNS, + Name: c.configEntryResource.Name(), + } + resp, err := r.Reconcile(ctrl.Request{ + NamespacedName: namespacedName, + }) + req.NoError(err) + req.False(resp.Requeue) + + cfg, _, err := consulClient.ConfigEntries().Get(c.consulKind, c.configEntryResource.Name(), nil) + req.NoError(err) + req.Equal(c.configEntryResource.Name(), cfg.GetName()) + c.compare(t, cfg) + + // Check that the status is "synced". + err = client.Get(ctx, namespacedName, c.configEntryResource) + req.NoError(err) + req.Equal(corev1.ConditionTrue, c.configEntryResource.SyncedConditionStatus()) + + // Check that the finalizer is added. + req.Contains(c.configEntryResource.Finalizers(), FinalizerName) + }) + } +} + +func TestConfigEntryControllers_updatesConfigEntry(t *testing.T) { + t.Parallel() + kubeNS := "default" + + cases := []struct { + kubeKind string + consulKind string + configEntryResource common.ConfigEntryResource + reconciler func(client.Client, *capi.Client, logr.Logger) testReconciler + updateF func(common.ConfigEntryResource) + compare func(t *testing.T, consul capi.ConfigEntry) + }{ + { + kubeKind: "ServiceDefaults", + consulKind: capi.ServiceDefaults, + configEntryResource: &v1alpha1.ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceDefaultsSpec{ + Protocol: "http", + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceDefaultsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + }, + } + }, + updateF: func(resource common.ConfigEntryResource) { + svcDefaults := resource.(*v1alpha1.ServiceDefaults) + svcDefaults.Spec.Protocol = "tcp" + }, + compare: func(t *testing.T, consulEntry capi.ConfigEntry) { + svcDefault, ok := consulEntry.(*capi.ServiceConfigEntry) + require.True(t, ok, "cast error") + require.Equal(t, "tcp", svcDefault.Protocol) + }, + }, + { + kubeKind: "ServiceResolver", + consulKind: capi.ServiceResolver, + configEntryResource: &v1alpha1.ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceResolverSpec{ + Redirect: &v1alpha1.ServiceResolverRedirect{ + Service: "redirect", + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceResolverController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + }, + } + }, + updateF: func(resource common.ConfigEntryResource) { + svcResolver := resource.(*v1alpha1.ServiceResolver) + svcResolver.Spec.Redirect.Service = "different_redirect" + }, + compare: func(t *testing.T, consulEntry capi.ConfigEntry) { + svcDefault, ok := consulEntry.(*capi.ServiceResolverConfigEntry) + require.True(t, ok, "cast error") + require.Equal(t, "different_redirect", svcDefault.Redirect.Service) + }, + }, + } + + for _, c := range cases { + t.Run(c.kubeKind, func(t *testing.T) { + req := require.New(t) + ctx := context.Background() + + s := runtime.NewScheme() + s.AddKnownTypes(v1alpha1.GroupVersion, c.configEntryResource) + client := fake.NewFakeClientWithScheme(s, c.configEntryResource) + + consul, err := testutil.NewTestServerConfigT(t, nil) + req.NoError(err) + defer consul.Stop() + consulClient, err := capi.NewClient(&capi.Config{ + Address: consul.HTTPAddr, + }) + req.NoError(err) + + // We haven't run reconcile yet so we must create the config entry + // in Consul ourselves. + { + written, _, err := consulClient.ConfigEntries().Set(c.configEntryResource.ToConsul(), nil) + req.NoError(err) + req.True(written) + } + + // Now run reconcile which should update the entry in Consul. + { + namespacedName := types.NamespacedName{ + Namespace: kubeNS, + Name: c.configEntryResource.Name(), + } + // First get it so we have the latest revision number. + err = client.Get(ctx, namespacedName, c.configEntryResource) + req.NoError(err) + + // Update the entry in Kube and run reconcile. + c.updateF(c.configEntryResource) + err := client.Update(ctx, c.configEntryResource) + req.NoError(err) + r := c.reconciler(client, consulClient, logrtest.TestLogger{T: t}) + resp, err := r.Reconcile(ctrl.Request{ + NamespacedName: namespacedName, + }) + req.NoError(err) + req.False(resp.Requeue) + + // Now check that the object in Consul is as expected. + cfg, _, err := consulClient.ConfigEntries().Get(c.consulKind, c.configEntryResource.Name(), nil) + req.NoError(err) + req.Equal(c.configEntryResource.Name(), cfg.GetName()) + c.compare(t, cfg) + } + }) + } +} + +func TestConfigEntryControllers_deletesConfigEntry(t *testing.T) { + t.Parallel() + kubeNS := "default" + + cases := []struct { + kubeKind string + consulKind string + configEntryResourceWithDeletion common.ConfigEntryResource + reconciler func(client.Client, *capi.Client, logr.Logger) testReconciler + }{ + { + kubeKind: "ServiceDefaults", + consulKind: capi.ServiceDefaults, + configEntryResourceWithDeletion: &v1alpha1.ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{FinalizerName}, + }, + Spec: v1alpha1.ServiceDefaultsSpec{ + Protocol: "http", + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceDefaultsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + }, + } + }, + }, + { + kubeKind: "ServiceResolver", + consulKind: capi.ServiceResolver, + configEntryResourceWithDeletion: &v1alpha1.ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{FinalizerName}, + }, + Spec: v1alpha1.ServiceResolverSpec{ + Redirect: &v1alpha1.ServiceResolverRedirect{ + Service: "redirect", + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceResolverController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + }, + } + }, + }, + } + + for _, c := range cases { + t.Run(c.kubeKind, func(t *testing.T) { + req := require.New(t) + + s := runtime.NewScheme() + s.AddKnownTypes(v1alpha1.GroupVersion, c.configEntryResourceWithDeletion) + client := fake.NewFakeClientWithScheme(s, c.configEntryResourceWithDeletion) + + consul, err := testutil.NewTestServerConfigT(t, nil) + req.NoError(err) + defer consul.Stop() + consulClient, err := capi.NewClient(&capi.Config{ + Address: consul.HTTPAddr, + }) + req.NoError(err) + + // We haven't run reconcile yet so we must create the config entry + // in Consul ourselves. + { + written, _, err := consulClient.ConfigEntries().Set(c.configEntryResourceWithDeletion.ToConsul(), nil) + req.NoError(err) + req.True(written) + } + + // Now run reconcile. It's marked for deletion so this should delete it. + { + namespacedName := types.NamespacedName{ + Namespace: kubeNS, + Name: c.configEntryResourceWithDeletion.Name(), + } + r := c.reconciler(client, consulClient, logrtest.TestLogger{T: t}) + resp, err := r.Reconcile(ctrl.Request{ + NamespacedName: namespacedName, + }) + req.NoError(err) + req.False(resp.Requeue) + + _, _, err = consulClient.ConfigEntries().Get(c.consulKind, c.configEntryResourceWithDeletion.Name(), nil) + req.EqualError(err, + fmt.Sprintf("Unexpected response code: 404 (Config entry not found for %q / %q)", + c.consulKind, c.configEntryResourceWithDeletion.Name())) + } + }) + } +} + +func TestConfigEntryControllers_errorUpdatesSyncStatus(t *testing.T) { + t.Parallel() + kubeNS := "default" + + cases := []struct { + kubeKind string + consulKind string + configEntryResource common.ConfigEntryResource + reconciler func(client.Client, *capi.Client, logr.Logger) testReconciler + }{ + { + kubeKind: "ServiceDefaults", + consulKind: capi.ServiceDefaults, + configEntryResource: &v1alpha1.ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceDefaultsSpec{ + Protocol: "http", + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceDefaultsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + }, + } + }, + }, + { + kubeKind: "ServiceResolver", + consulKind: capi.ServiceResolver, + configEntryResource: &v1alpha1.ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceResolverSpec{ + Redirect: &v1alpha1.ServiceResolverRedirect{ + Service: "redirect", + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceResolverController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + }, + } + }, + }, + } + + for _, c := range cases { + t.Run(c.kubeKind, func(t *testing.T) { + req := require.New(t) + ctx := context.Background() + + s := runtime.NewScheme() + s.AddKnownTypes(v1alpha1.GroupVersion, c.configEntryResource) + client := fake.NewFakeClientWithScheme(s, c.configEntryResource) + + // Construct a Consul client that will error by giving it + // an unresolvable address. + consulClient, err := capi.NewClient(&capi.Config{ + Address: "incorrect-address", + }) + req.NoError(err) + + // ReconcileEntry should result in an error. + r := c.reconciler(client, consulClient, logrtest.TestLogger{T: t}) + namespacedName := types.NamespacedName{ + Namespace: kubeNS, + Name: c.configEntryResource.Name(), + } + resp, err := r.Reconcile(ctrl.Request{ + NamespacedName: namespacedName, + }) + req.Error(err) + + expErr := fmt.Sprintf("Get \"http://incorrect-address/v1/config/%s/%s\": dial tcp: lookup incorrect-address", c.consulKind, c.configEntryResource.Name()) + req.Contains(err.Error(), expErr) + req.False(resp.Requeue) + + // Check that the status is "synced=false". + err = client.Get(ctx, namespacedName, c.configEntryResource) + req.NoError(err) + status, reason, errMsg := c.configEntryResource.SyncedCondition() + req.Equal(corev1.ConditionFalse, status) + req.Equal("ConsulAgentError", reason) + req.Contains(errMsg, expErr) + }) + } +} diff --git a/controllers/servicedefaults_controller.go b/controllers/servicedefaults_controller.go index dd431e1dcb..ace31b16b3 100644 --- a/controllers/servicedefaults_controller.go +++ b/controllers/servicedefaults_controller.go @@ -2,262 +2,41 @@ package controllers import ( "context" - "fmt" - "strings" "github.com/go-logr/logr" - "github.com/hashicorp/consul-k8s/namespaces" - capi "github.com/hashicorp/consul/api" - corev1 "k8s.io/api/core/v1" - k8serr "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" consulv1alpha1 "github.com/hashicorp/consul-k8s/api/v1alpha1" ) -const ( - FinalizerName = "finalizers.consul.hashicorp.com" - ConsulAgentError = "ConsulAgentError" - CastError = "CastError" -) - -// ServiceDefaultsReconciler reconciles a ServiceDefaults object -type ServiceDefaultsReconciler struct { +// ServiceDefaultsController is the controller for ServiceDefaults resources. +type ServiceDefaultsController struct { client.Client - Log logr.Logger - Scheme *runtime.Scheme - ConsulClient *capi.Client - - // EnableConsulNamespaces indicates that a user is running Consul Enterprise - // with version 1.7+ which supports namespaces. - EnableConsulNamespaces bool - - // ConsulDestinationNamespace is the name of the Consul namespace to create - // all config entries in. If EnableNSMirroring is true this is ignored. - ConsulDestinationNamespace string - - // EnableNSMirroring causes Consul namespaces to be created to match the - // k8s namespace of any config entry custom resource. Config entries will - // be created in the matching Consul namespace. - EnableNSMirroring bool - - // NSMirroringPrefix is an optional prefix that can be added to the Consul - // namespaces created while mirroring. For example, if it is set to "k8s-", - // then the k8s `default` namespace will be mirrored in Consul's - // `k8s-default` namespace. - NSMirroringPrefix string - - // CrossNSACLPolicy is the name of the ACL policy to attach to - // any created Consul namespaces to allow cross namespace service discovery. - // Only necessary if ACLs are enabled. - CrossNSACLPolicy string + Log logr.Logger + Scheme *runtime.Scheme + ConfigEntryController *ConfigEntryController } // +kubebuilder:rbac:groups=consul.hashicorp.com,resources=servicedefaults,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=consul.hashicorp.com,resources=servicedefaults/status,verbs=get;update;patch -func (r *ServiceDefaultsReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) { - ctx := context.Background() - logger := r.Log.WithValues("controller", "servicedefaults", "request", req.NamespacedName) - var svcDefaults consulv1alpha1.ServiceDefaults - - err := r.Get(ctx, req.NamespacedName, &svcDefaults) - if k8serr.IsNotFound(err) { - return ctrl.Result{}, client.IgnoreNotFound(err) - } else if err != nil { - logger.Error(err, "retrieving resource") - return ctrl.Result{}, err - } - - if svcDefaults.ObjectMeta.DeletionTimestamp.IsZero() { - // The object is not being deleted, so if it does not have our finalizer, - // then lets add the finalizer and update the object. This is equivalent - // registering our finalizer. - if !containsString(svcDefaults.ObjectMeta.Finalizers, FinalizerName) { - svcDefaults.ObjectMeta.Finalizers = append(svcDefaults.ObjectMeta.Finalizers, FinalizerName) - if err := r.syncUnknown(&svcDefaults); err != nil { - return ctrl.Result{}, err - } - } - } else { - // The object is being deleted - if containsString(svcDefaults.ObjectMeta.Finalizers, FinalizerName) { - logger.Info("deletion event") - // Our finalizer is present, so we need to delete the config entry - // from consul. - _, err = r.ConsulClient.ConfigEntries().Delete(capi.ServiceDefaults, svcDefaults.Name, &capi.WriteOptions{ - Namespace: r.consulNamespace(req.Namespace), - }) - if err != nil { - return ctrl.Result{}, fmt.Errorf("deleting config entry from consul: %w", err) - } - logger.Info("deletion from Consul successful") - - // remove our finalizer from the list and update it. - svcDefaults.ObjectMeta.Finalizers = removeString(svcDefaults.ObjectMeta.Finalizers, FinalizerName) - if err := r.Update(context.Background(), &svcDefaults); err != nil { - return ctrl.Result{}, err - } - logger.Info("finalizer removed") - } - - // Stop reconciliation as the item is being deleted - return ctrl.Result{}, nil - } - - // Check to see if consul has service defaults with the same name - entry, _, err := r.ConsulClient.ConfigEntries().Get(capi.ServiceDefaults, svcDefaults.Name, &capi.QueryOptions{ - Namespace: r.consulNamespace(req.Namespace), - }) - // If a config entry with this name does not exist - if isNotFoundErr(err) { - logger.Info("config entry not found in consul") - - // If Consul namespaces are enabled we may need to create the - // destination consul namespace first. - if r.EnableConsulNamespaces { - if err := namespaces.EnsureExists(r.ConsulClient, r.consulNamespace(req.Namespace), r.CrossNSACLPolicy); err != nil { - return r.syncFailed(logger, svcDefaults, ConsulAgentError, - fmt.Errorf("creating consul namespace %q: %w", r.consulNamespace(req.Namespace), err)) - } - } - - // Create the config entry - _, _, err := r.ConsulClient.ConfigEntries().Set(svcDefaults.ToConsul(), &capi.WriteOptions{ - Namespace: r.consulNamespace(req.Namespace), - }) - if err != nil { - return r.syncFailed(logger, svcDefaults, ConsulAgentError, - fmt.Errorf("writing config entry to consul: %w", err)) - } - return r.syncSuccessful(svcDefaults) - } - - // If there is an error when trying to get the config entry from the api server, - // fail the reconcile. - if err != nil { - return r.syncFailed(logger, svcDefaults, ConsulAgentError, err) - } +func (r *ServiceDefaultsController) Reconcile(req ctrl.Request) (ctrl.Result, error) { + return r.ConfigEntryController.ReconcileEntry(r, req, &consulv1alpha1.ServiceDefaults{}) +} - svcDefaultEntry, ok := entry.(*capi.ServiceConfigEntry) - if !ok { - return r.syncUnknownWithError(logger, svcDefaults, CastError, - fmt.Errorf("could not cast entry as ServiceConfigEntry")) - } - if !svcDefaults.MatchesConsul(svcDefaultEntry) { - _, _, err := r.ConsulClient.ConfigEntries().Set(svcDefaults.ToConsul(), &capi.WriteOptions{ - Namespace: r.consulNamespace(req.Namespace), - }) - if err != nil { - return r.syncUnknownWithError(logger, svcDefaults, ConsulAgentError, - fmt.Errorf("updating config entry in consul: %w", err)) - } - return r.syncSuccessful(svcDefaults) - } else if !svcDefaults.Status.GetCondition(consulv1alpha1.ConditionSynced).IsTrue() { - return r.syncSuccessful(svcDefaults) - } +func (r *ServiceDefaultsController) Logger(name types.NamespacedName) logr.Logger { + return r.Log.WithValues("request", name) +} - return ctrl.Result{}, nil +func (r *ServiceDefaultsController) UpdateStatus(ctx context.Context, obj runtime.Object, opts ...client.UpdateOption) error { + return r.Status().Update(ctx, obj, opts...) } -func (r *ServiceDefaultsReconciler) SetupWithManager(mgr ctrl.Manager) error { +func (r *ServiceDefaultsController) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&consulv1alpha1.ServiceDefaults{}). Complete(r) } - -// consulNamespace returns the consul namespace that a service should be -// registered in based on the namespace options. It returns an -// empty string if namespaces aren't enabled. -func (r *ServiceDefaultsReconciler) consulNamespace(ns string) string { - return namespaces.ConsulNamespace(ns, r.EnableConsulNamespaces, r.ConsulDestinationNamespace, r.EnableNSMirroring, r.NSMirroringPrefix) -} - -func (r *ServiceDefaultsReconciler) syncFailed(logger logr.Logger, svcDefaults consulv1alpha1.ServiceDefaults, errType string, err error) (ctrl.Result, error) { - svcDefaults.Status.Conditions = consulv1alpha1.Conditions{ - { - Type: consulv1alpha1.ConditionSynced, - Status: corev1.ConditionFalse, - LastTransitionTime: metav1.Now(), - Reason: errType, - Message: err.Error(), - }, - } - if updateErr := r.Status().Update(context.Background(), &svcDefaults); updateErr != nil { - // Log the original error here because we are returning the updateErr. - // Otherwise the original error would be lost. - logger.Error(err, "sync failed") - return ctrl.Result{}, updateErr - } - return ctrl.Result{}, err -} - -func (r *ServiceDefaultsReconciler) syncSuccessful(svcDefaults consulv1alpha1.ServiceDefaults) (ctrl.Result, error) { - svcDefaults.Status.Conditions = consulv1alpha1.Conditions{ - { - Type: consulv1alpha1.ConditionSynced, - Status: corev1.ConditionTrue, - LastTransitionTime: metav1.Now(), - }, - } - return ctrl.Result{}, r.Status().Update(context.Background(), &svcDefaults) -} - -func (r *ServiceDefaultsReconciler) syncUnknown(svcDefaults *consulv1alpha1.ServiceDefaults) error { - svcDefaults.Status.Conditions = consulv1alpha1.Conditions{ - { - Type: consulv1alpha1.ConditionSynced, - Status: corev1.ConditionUnknown, - LastTransitionTime: metav1.Now(), - }, - } - return r.Update(context.Background(), svcDefaults) -} - -func (r *ServiceDefaultsReconciler) syncUnknownWithError(logger logr.Logger, svcDefaults consulv1alpha1.ServiceDefaults, errType string, err error) (ctrl.Result, error) { - svcDefaults.Status.Conditions = consulv1alpha1.Conditions{ - { - Type: consulv1alpha1.ConditionSynced, - Status: corev1.ConditionUnknown, - LastTransitionTime: metav1.Now(), - Reason: errType, - Message: err.Error(), - }, - } - if updateErr := r.Status().Update(context.Background(), &svcDefaults); updateErr != nil { - // Log the original error here because we are returning the updateErr. - // Otherwise the original error would be lost. - logger.Error(err, "sync status unknown") - return ctrl.Result{}, updateErr - } - return ctrl.Result{}, err -} - -func isNotFoundErr(err error) bool { - return err != nil && strings.Contains(err.Error(), "404") -} - -// containsString returns true if s is in slice. -func containsString(slice []string, s string) bool { - for _, item := range slice { - if item == s { - return true - } - } - return false -} - -// removeString removes s from slice and returns the new slice. -func removeString(slice []string, s string) []string { - var result []string - for _, item := range slice { - if item == s { - continue - } - result = append(result, item) - } - return result -} diff --git a/controllers/servicedefaults_controller_test.go b/controllers/servicedefaults_controller_test.go deleted file mode 100644 index 73afb33882..0000000000 --- a/controllers/servicedefaults_controller_test.go +++ /dev/null @@ -1,314 +0,0 @@ -package controllers_test - -import ( - "context" - "testing" - "time" - - logrtest "github.com/go-logr/logr/testing" - "github.com/hashicorp/consul-k8s/api/v1alpha1" - "github.com/hashicorp/consul-k8s/controllers" - capi "github.com/hashicorp/consul/api" - "github.com/hashicorp/consul/sdk/testutil" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/kubernetes/scheme" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client/fake" -) - -func TestServiceDefaultsController_createsConfigEntry(t *testing.T) { - req := require.New(t) - svcDefaults := &v1alpha1.ServiceDefaults{ - ObjectMeta: metav1.ObjectMeta{ - Name: "foo", - Namespace: "default", - }, - Spec: v1alpha1.ServiceDefaultsSpec{ - Protocol: "http", - }, - } - s := scheme.Scheme - s.AddKnownTypes(v1alpha1.GroupVersion, svcDefaults) - ctx := context.Background() - - consul, err := testutil.NewTestServerConfigT(t, nil) - req.NoError(err) - defer consul.Stop() - consulClient, err := capi.NewClient(&capi.Config{ - Address: consul.HTTPAddr, - }) - req.NoError(err) - - client := fake.NewFakeClientWithScheme(s, svcDefaults) - - r := controllers.ServiceDefaultsReconciler{ - Client: client, - Log: logrtest.TestLogger{T: t}, - Scheme: s, - ConsulClient: consulClient, - } - - resp, err := r.Reconcile(ctrl.Request{ - NamespacedName: types.NamespacedName{ - Namespace: svcDefaults.ObjectMeta.Namespace, - Name: svcDefaults.ObjectMeta.Name, - }, - }) - req.NoError(err) - req.False(resp.Requeue) - - cfg, _, err := consulClient.ConfigEntries().Get(capi.ServiceDefaults, "foo", nil) - req.NoError(err) - svcDefault, ok := cfg.(*capi.ServiceConfigEntry) - req.True(ok) - req.Equal("http", svcDefault.Protocol) - - // Check that the status is "synced". - err = client.Get(ctx, types.NamespacedName{ - Namespace: svcDefaults.Namespace, - Name: svcDefaults.Name, - }, svcDefaults) - req.NoError(err) - conditionSynced := svcDefaults.Status.GetCondition(v1alpha1.ConditionSynced) - req.True(conditionSynced.IsTrue()) -} - -func TestServiceDefaultsController_addsFinalizerOnCreate(t *testing.T) { - req := require.New(t) - s := scheme.Scheme - svcDefaults := &v1alpha1.ServiceDefaults{ - ObjectMeta: metav1.ObjectMeta{ - Name: "foo", - Namespace: "default", - }, - Spec: v1alpha1.ServiceDefaultsSpec{ - Protocol: "http", - }, - } - s.AddKnownTypes(v1alpha1.GroupVersion, svcDefaults) - - consul, err := testutil.NewTestServerConfigT(t, nil) - req.NoError(err) - defer consul.Stop() - consulClient, err := capi.NewClient(&capi.Config{ - Address: consul.HTTPAddr, - }) - req.NoError(err) - - client := fake.NewFakeClientWithScheme(s, svcDefaults) - - r := controllers.ServiceDefaultsReconciler{ - Client: client, - Log: logrtest.TestLogger{T: t}, - Scheme: s, - ConsulClient: consulClient, - } - - resp, err := r.Reconcile(ctrl.Request{ - NamespacedName: types.NamespacedName{ - Namespace: svcDefaults.ObjectMeta.Namespace, - Name: svcDefaults.ObjectMeta.Name, - }, - }) - req.NoError(err) - req.False(resp.Requeue) - - err = client.Get(context.Background(), types.NamespacedName{ - Namespace: svcDefaults.Namespace, - Name: svcDefaults.Name, - }, svcDefaults) - req.NoError(err) - req.Contains(svcDefaults.Finalizers, controllers.FinalizerName) - conditionSynced := svcDefaults.Status.GetCondition(v1alpha1.ConditionSynced) - req.True(conditionSynced.IsTrue()) -} - -func TestServiceDefaultsController_updatesConfigEntry(t *testing.T) { - req := require.New(t) - svcDefaults := &v1alpha1.ServiceDefaults{ - ObjectMeta: metav1.ObjectMeta{ - Name: "foo", - Namespace: "default", - Finalizers: []string{controllers.FinalizerName}, - }, - Spec: v1alpha1.ServiceDefaultsSpec{ - Protocol: "http", - }, - } - s := scheme.Scheme - s.AddKnownTypes(v1alpha1.GroupVersion, svcDefaults) - ctx := context.Background() - - consul, err := testutil.NewTestServerConfigT(t, nil) - req.NoError(err) - defer consul.Stop() - consulClient, err := capi.NewClient(&capi.Config{ - Address: consul.HTTPAddr, - }) - req.NoError(err) - - client := fake.NewFakeClientWithScheme(s, svcDefaults) - - r := controllers.ServiceDefaultsReconciler{ - Client: client, - Log: logrtest.TestLogger{T: t}, - Scheme: s, - ConsulClient: consulClient, - } - - // We haven't run reconcile yet so ensure it's created in Consul. - { - written, _, err := consulClient.ConfigEntries().Set(&capi.ServiceConfigEntry{ - Kind: capi.ServiceDefaults, - Name: "foo", - Protocol: "http", - }, nil) - req.NoError(err) - req.True(written) - } - - // Now update it. - { - // First get it so we have the latest revision number. - err = client.Get(ctx, types.NamespacedName{ - Namespace: svcDefaults.Namespace, - Name: svcDefaults.Name, - }, svcDefaults) - req.NoError(err) - - // Update the protocol. - svcDefaults.Spec.Protocol = "tcp" - err := client.Update(ctx, svcDefaults) - req.NoError(err) - - resp, err := r.Reconcile(ctrl.Request{ - NamespacedName: types.NamespacedName{ - Namespace: svcDefaults.ObjectMeta.Namespace, - Name: svcDefaults.ObjectMeta.Name, - }, - }) - req.NoError(err) - req.False(resp.Requeue) - - cfg, _, err := consulClient.ConfigEntries().Get(capi.ServiceDefaults, "foo", nil) - req.NoError(err) - svcDefault, ok := cfg.(*capi.ServiceConfigEntry) - req.True(ok) - req.Equal("tcp", svcDefault.Protocol) - } -} - -func TestServiceDefaultsController_deletesConfigEntry(t *testing.T) { - req := require.New(t) - // Create it with the deletion timestamp set to mimic that it's already - // been marked for deletion. - svcDefaults := &v1alpha1.ServiceDefaults{ - ObjectMeta: metav1.ObjectMeta{ - Name: "foo", - Namespace: "default", - DeletionTimestamp: &metav1.Time{Time: time.Now()}, - Finalizers: []string{controllers.FinalizerName}, - }, - Spec: v1alpha1.ServiceDefaultsSpec{ - Protocol: "http", - }, - } - s := scheme.Scheme - s.AddKnownTypes(v1alpha1.GroupVersion, svcDefaults) - - consul, err := testutil.NewTestServerConfigT(t, nil) - req.NoError(err) - defer consul.Stop() - consulClient, err := capi.NewClient(&capi.Config{ - Address: consul.HTTPAddr, - }) - req.NoError(err) - - client := fake.NewFakeClientWithScheme(s, svcDefaults) - - r := controllers.ServiceDefaultsReconciler{ - Client: client, - Log: logrtest.TestLogger{T: t}, - Scheme: s, - ConsulClient: consulClient, - } - - // We haven't run reconcile yet so ensure it's created in Consul. - { - written, _, err := consulClient.ConfigEntries().Set(&capi.ServiceConfigEntry{ - Kind: capi.ServiceDefaults, - Name: "foo", - Protocol: "http", - }, nil) - req.NoError(err) - req.True(written) - } - - // Now run reconcile. It's marked for deletion so this should delete it. - { - resp, err := r.Reconcile(ctrl.Request{ - NamespacedName: types.NamespacedName{ - Namespace: svcDefaults.ObjectMeta.Namespace, - Name: svcDefaults.ObjectMeta.Name, - }, - }) - req.NoError(err) - req.False(resp.Requeue) - - _, _, err = consulClient.ConfigEntries().Get(capi.ServiceDefaults, "foo", nil) - req.EqualError(err, "Unexpected response code: 404 (Config entry not found for \"service-defaults\" / \"foo\")") - } -} - -func TestServiceDefaultsController_errorUpdatesSyncStatus(t *testing.T) { - req := require.New(t) - svcDefaults := &v1alpha1.ServiceDefaults{ - ObjectMeta: metav1.ObjectMeta{ - Name: "foo", - Namespace: "default", - }, - Spec: v1alpha1.ServiceDefaultsSpec{ - Protocol: "http", - }, - } - s := scheme.Scheme - s.AddKnownTypes(v1alpha1.GroupVersion, svcDefaults) - ctx := context.Background() - - consulClient, err := capi.NewClient(&capi.Config{ - Address: "incorrect-address", - }) - req.NoError(err) - - client := fake.NewFakeClientWithScheme(s, svcDefaults) - - r := controllers.ServiceDefaultsReconciler{ - Client: client, - Log: logrtest.TestLogger{T: t}, - Scheme: s, - ConsulClient: consulClient, - } - - resp, err := r.Reconcile(ctrl.Request{ - NamespacedName: types.NamespacedName{ - Namespace: svcDefaults.Namespace, - Name: svcDefaults.Name, - }, - }) - req.Error(err) - req.Contains(err.Error(), "Get \"http://incorrect-address/v1/config/service-defaults/foo\": dial tcp: lookup incorrect-address") - req.False(resp.Requeue) - - // Check that the status is "synced=false". - err = client.Get(ctx, types.NamespacedName{ - Namespace: svcDefaults.Namespace, - Name: svcDefaults.Name, - }, svcDefaults) - req.NoError(err) - conditionSynced := svcDefaults.Status.GetCondition(v1alpha1.ConditionSynced) - req.True(conditionSynced.IsFalse()) - req.Equal("ConsulAgentError", conditionSynced.Reason) - req.Contains(conditionSynced.Message, "Get \"http://incorrect-address/v1/config/service-defaults/foo\": dial tcp: lookup incorrect-address", conditionSynced.Message) -} diff --git a/controllers/serviceresolver_controller.go b/controllers/serviceresolver_controller.go new file mode 100644 index 0000000000..4d50309f65 --- /dev/null +++ b/controllers/serviceresolver_controller.go @@ -0,0 +1,42 @@ +package controllers + +import ( + "context" + + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + consulv1alpha1 "github.com/hashicorp/consul-k8s/api/v1alpha1" +) + +// ServiceResolverController is the controller for ServiceResolver resources. +type ServiceResolverController struct { + client.Client + Log logr.Logger + Scheme *runtime.Scheme + ConfigEntryController *ConfigEntryController +} + +// +kubebuilder:rbac:groups=consul.hashicorp.com,resources=serviceresolvers,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=consul.hashicorp.com,resources=serviceresolvers/status,verbs=get;update;patch + +func (r *ServiceResolverController) Reconcile(req ctrl.Request) (ctrl.Result, error) { + return r.ConfigEntryController.ReconcileEntry(r, req, &consulv1alpha1.ServiceResolver{}) +} + +func (r *ServiceResolverController) Logger(name types.NamespacedName) logr.Logger { + return r.Log.WithValues("request", name) +} + +func (r *ServiceResolverController) UpdateStatus(ctx context.Context, obj runtime.Object, opts ...client.UpdateOption) error { + return r.Status().Update(ctx, obj, opts...) +} + +func (r *ServiceResolverController) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&consulv1alpha1.ServiceResolver{}). + Complete(r) +} diff --git a/go.mod b/go.mod index 9226956b5a..b072fa73ac 100644 --- a/go.mod +++ b/go.mod @@ -38,10 +38,10 @@ require ( golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect google.golang.org/api v0.9.0 // indirect google.golang.org/appengine v1.6.0 // indirect - k8s.io/api v0.18.2 - k8s.io/apimachinery v0.18.2 - k8s.io/client-go v0.18.2 - sigs.k8s.io/controller-runtime v0.6.0 + k8s.io/api v0.18.6 + k8s.io/apimachinery v0.18.6 + k8s.io/client-go v0.18.6 + sigs.k8s.io/controller-runtime v0.6.3 ) go 1.14 diff --git a/go.sum b/go.sum index 8d16fba9c5..ebc6cd6fde 100644 --- a/go.sum +++ b/go.sum @@ -139,12 +139,16 @@ github.com/evanphx/json-patch v4.2.0+incompatible h1:fUDGZCv/7iAN7u0puUVhvKCcsR6 github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch v4.5.0+incompatible h1:ouOWdg56aJriqS0huScTkVXPC5IcNrDCXZ6OoTAWu7M= github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v4.9.0+incompatible h1:kLcOMZeuLAJvL2BPWLMIj5oaZQobrkAqrL+WFZwQses= +github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= @@ -326,6 +330,8 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.9 h1:UauaLniWCFHWd+Jp9oCEkTBj8VO/9DKg3PV3VCNMDIg= +github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ= github.com/jackc/pgx v3.3.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= @@ -344,6 +350,8 @@ github.com/json-iterator/go v1.1.8 h1:QiWkFLKq0T7mpzwOTu6BzNDbfTE8OLrYhVKYMLF46O github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= @@ -480,6 +488,8 @@ github.com/prometheus/procfs v0.0.2 h1:6LJUbpNm42llc4HRCuvApCSWB/WfhuNo9K98Q9sNG github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8 h1:+fpWZdT24pJBiqJdAwYBjPSk+5YmQzYNPYzQsdzLkt8= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.0.11 h1:DhHlBtkHWPYi8O2y31JkK0TF+DGM+51OopZjH/Ia5qI= +github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/radovskyb/watcher v1.0.2 h1:9L5TsZUbo1nKhQEQPtICVc+x9UZQ6VPdBepLHyGw/bQ= github.com/radovskyb/watcher v1.0.2/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg= @@ -648,10 +658,12 @@ golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9 h1:1/DFK4b7JH8DmkqhUk48onnSfrPzImPoVxuomtbT2nk= @@ -667,6 +679,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -764,28 +778,47 @@ honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= k8s.io/api v0.18.2 h1:wG5g5ZmSVgm5B+eHMIbI9EGATS2L8Z72rda19RIEgY8= k8s.io/api v0.18.2/go.mod h1:SJCWI7OLzhZSvbY7U8zwNl9UA4o1fizoug34OV/2r78= +k8s.io/api v0.18.6 h1:osqrAXbOQjkKIWDTjrqxWQ3w0GkKb1KA1XkUGHHYpeE= +k8s.io/api v0.18.6/go.mod h1:eeyxr+cwCjMdLAmr2W3RyDI0VvTawSg/3RFFBEnmZGI= k8s.io/apiextensions-apiserver v0.18.2 h1:I4v3/jAuQC+89L3Z7dDgAiN4EOjN6sbm6iBqQwHTah8= k8s.io/apiextensions-apiserver v0.18.2/go.mod h1:q3faSnRGmYimiocj6cHQ1I3WpLqmDgJFlKL37fC4ZvY= +k8s.io/apiextensions-apiserver v0.18.6 h1:vDlk7cyFsDyfwn2rNAO2DbmUbvXy5yT5GE3rrqOzaMo= +k8s.io/apiextensions-apiserver v0.18.6/go.mod h1:lv89S7fUysXjLZO7ke783xOwVTm6lKizADfvUM/SS/M= k8s.io/apimachinery v0.18.2 h1:44CmtbmkzVDAhCpRVSiP2R5PPrC2RtlIv/MoB8xpdRA= k8s.io/apimachinery v0.18.2/go.mod h1:9SnR/e11v5IbyPCGbvJViimtJ0SwHG4nfZFjU77ftcA= +k8s.io/apimachinery v0.18.6 h1:RtFHnfGNfd1N0LeSrKCUznz5xtUP1elRGvHJbL3Ntag= +k8s.io/apimachinery v0.18.6/go.mod h1:OaXp26zu/5J7p0f92ASynJa1pZo06YlV9fG7BoWbCko= k8s.io/apiserver v0.18.2/go.mod h1:Xbh066NqrZO8cbsoenCwyDJ1OSi8Ag8I2lezeHxzwzw= +k8s.io/apiserver v0.18.6/go.mod h1:Zt2XvTHuaZjBz6EFYzpp+X4hTmgWGy8AthNVnTdm3Wg= 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/client-go v0.18.6 h1:I+oWqJbibLSGsZj8Xs8F0aWVXJVIoUHWaaJV3kUN/Zw= +k8s.io/client-go v0.18.6/go.mod h1:/fwtGLjYMS1MaM5oi+eXhKwG+1UHidUEXRh6cNsdO0Q= k8s.io/code-generator v0.18.2/go.mod h1:+UHX5rSbxmR8kzS+FAv7um6dtYrZokQvjHpDSYRVkTc= +k8s.io/code-generator v0.18.6/go.mod h1:TgNEVx9hCyPGpdtCWA34olQYLkh3ok9ar7XfSsr8b6c= k8s.io/component-base v0.18.2/go.mod h1:kqLlMuhJNHQ9lz8Z7V5bxUUtjFZnrypArGl58gmDfUM= +k8s.io/component-base v0.18.6/go.mod h1:knSVsibPR5K6EW2XOjEHik6sdU5nCvKMrzMt2D4In14= k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= k8s.io/gengo v0.0.0-20200114144118-36b2048a9120/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8= k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= +k8s.io/klog/v2 v2.0.0 h1:Foj74zO6RbjjP4hBEKjnYtjjAhGg4jNynUdYF6fJrok= +k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= k8s.io/kube-openapi v0.0.0-20200121204235-bf4fb3bd569c h1:/KUFqjjqAcY4Us6luF5RDNZ16KJtb49HfR3ZHB9qYXM= k8s.io/kube-openapi v0.0.0-20200121204235-bf4fb3bd569c/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E= +k8s.io/kube-openapi v0.0.0-20200410145947-61e04a5be9a6 h1:Oh3Mzx5pJ+yIumsAD0MOECPVeXsVot0UkiaCGVyfGQY= +k8s.io/kube-openapi v0.0.0-20200410145947-61e04a5be9a6/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E= k8s.io/utils v0.0.0-20200324210504-a9aa75ae1b89 h1:d4vVOjXm687F1iLSP2q3lyPPuyvTUt3aVoBpi2DqRsU= k8s.io/utils v0.0.0-20200324210504-a9aa75ae1b89/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= +k8s.io/utils v0.0.0-20200603063816-c1c6865ac451 h1:v8ud2Up6QK1lNOKFgiIVrZdMg7MpmSnvtrOieolJKoE= +k8s.io/utils v0.0.0-20200603063816-c1c6865ac451/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.7/go.mod h1:PHgbrJT7lCHcxMU+mDHEm+nx46H4zuuHZkDP6icnhu0= sigs.k8s.io/controller-runtime v0.6.0 h1:Fzna3DY7c4BIP6KwfSlrfnj20DJ+SeMBK8HSFvOk9NM= sigs.k8s.io/controller-runtime v0.6.0/go.mod h1:CpYf5pdNY/B352A1TFLAS2JVSlnGQ5O2cftPHndTroo= +sigs.k8s.io/controller-runtime v0.6.3 h1:SBbr+inLPEKhvlJtrvDcwIpm+uhDvp63Bl72xYJtoOE= +sigs.k8s.io/controller-runtime v0.6.3/go.mod h1:WlZNXcM0++oyaQt4B7C2lEE5JYRs8vJUzRP4N4JpdAY= sigs.k8s.io/structured-merge-diff/v3 v3.0.0-20200116222232-67a7b8c61874/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw= sigs.k8s.io/structured-merge-diff/v3 v3.0.0 h1:dOmIZBMfhcHS09XZkMyUgkq5trg3/jRyJYFZUiaOp8E= sigs.k8s.io/structured-merge-diff/v3 v3.0.0/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw= diff --git a/subcommand/controller/command.go b/subcommand/controller/command.go index 8fa2861781..05d71906d7 100644 --- a/subcommand/controller/command.go +++ b/subcommand/controller/command.go @@ -111,28 +111,44 @@ func (c *Command) Run(args []string) int { return 1 } - if err = (&controllers.ServiceDefaultsReconciler{ - Client: mgr.GetClient(), - Log: ctrl.Log.WithName("controllers").WithName("ServiceDefaults"), - Scheme: mgr.GetScheme(), + configEntryReconciler := &controllers.ConfigEntryController{ ConsulClient: consulClient, EnableConsulNamespaces: c.flagEnableNamespaces, ConsulDestinationNamespace: c.flagConsulDestinationNamespace, EnableNSMirroring: c.flagEnableNSMirroring, NSMirroringPrefix: c.flagNSMirroringPrefix, CrossNSACLPolicy: c.flagCrossNSACLPolicy, + } + if err = (&controllers.ServiceDefaultsController{ + ConfigEntryController: configEntryReconciler, + Client: mgr.GetClient(), + Log: ctrl.Log.WithName("controllers").WithName("ServiceDefaults"), + Scheme: mgr.GetScheme(), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ServiceDefaults") return 1 } + if err = (&controllers.ServiceResolverController{ + ConfigEntryController: configEntryReconciler, + Client: mgr.GetClient(), + Log: ctrl.Log.WithName("controllers").WithName("ServiceResolver"), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ServiceResolver") + return 1 + } if os.Getenv("ENABLE_WEBHOOKS") != "false" { // This webhook server sets up a Cert Watcher on the CertDir. This watches for file changes and updates the webhook certificates // automatically when new certificates are available. mgr.GetWebhookServer().CertDir = c.flagWebhookTLSCertDir - // Note: The path here should be identical to the one on the kubebuilder annotation in file api/v1alpha1/servicedefaults_webhook.go + + // Note: The path here should be identical to the one on the kubebuilder + // annotation in each webhook file. mgr.GetWebhookServer().Register("/mutate-v1alpha1-servicedefaults", &webhook.Admission{Handler: v1alpha1.NewServiceDefaultsValidator(mgr.GetClient(), consulClient, ctrl.Log.WithName("webhooks").WithName("ServiceDefaults"))}) + mgr.GetWebhookServer().Register("/mutate-v1alpha1-serviceresolver", + &webhook.Admission{Handler: v1alpha1.NewServiceResolverValidator(mgr.GetClient(), consulClient, ctrl.Log.WithName("webhooks").WithName("ServiceResolver"))}) } // +kubebuilder:scaffold:builder