diff --git a/.changelog/3943.txt b/.changelog/3943.txt new file mode 100644 index 0000000000..3be45fc453 --- /dev/null +++ b/.changelog/3943.txt @@ -0,0 +1,3 @@ +```release-note:feature +control-plane: Add the ability to register services via CRD. +``` diff --git a/charts/consul/templates/connect-inject-clusterrole.yaml b/charts/consul/templates/connect-inject-clusterrole.yaml index be816ff391..9c8596b05b 100644 --- a/charts/consul/templates/connect-inject-clusterrole.yaml +++ b/charts/consul/templates/connect-inject-clusterrole.yaml @@ -32,6 +32,7 @@ rules: - routetimeoutfilters - routeauthfilters - gatewaypolicies + - registrations {{- if .Values.global.peering.enabled }} - peeringacceptors - peeringdialers @@ -61,6 +62,7 @@ rules: - terminatinggateways/status - samenessgroups/status - controlplanerequestlimits/status + - registrations/status {{- if .Values.global.peering.enabled }} - peeringacceptors/status - peeringdialers/status diff --git a/charts/consul/templates/crd-registrations.yaml b/charts/consul/templates/crd-registrations.yaml new file mode 100644 index 0000000000..c693977636 --- /dev/null +++ b/charts/consul/templates/crd-registrations.yaml @@ -0,0 +1,254 @@ +{{- if .Values.connectInject.enabled }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.12.1 + labels: + app: {{ template "consul.name" . }} + chart: {{ template "consul.chart" . }} + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + component: crd + name: registrations.consul.hashicorp.com +spec: + group: consul.hashicorp.com + names: + kind: Registration + listKind: RegistrationList + plural: registrations + singular: registration + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: Registration defines the resource for working with service registrations. + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: Spec defines the desired state of Registration. + properties: + address: + type: string + check: + description: HealthCheck is used to represent a single check. + properties: + checkId: + type: string + definition: + description: HealthCheckDefinition is used to store the details + about a health check's execution. + properties: + body: + type: string + deregisterCriticalServiceAfterDuration: + type: string + grpc: + type: string + grpcUseTLS: + type: boolean + header: + additionalProperties: + items: + type: string + type: array + type: object + http: + type: string + intervalDuration: + type: string + method: + type: string + osService: + type: string + tcp: + type: string + tcpUseTLS: + type: boolean + timeoutDuration: + type: string + tlsServerName: + type: string + tlsSkipVerify: + type: boolean + udp: + type: string + required: + - deregisterCriticalServiceAfterDuration + - intervalDuration + - timeoutDuration + type: object + exposedPort: + type: integer + name: + type: string + namespace: + type: string + node: + type: string + notes: + type: string + output: + type: string + partition: + type: string + serviceId: + type: string + serviceName: + type: string + status: + type: string + type: + type: string + required: + - checkId + - definition + - name + - node + - serviceId + - serviceName + - status + type: object + datacenter: + type: string + id: + type: string + locality: + properties: + region: + type: string + zone: + type: string + type: object + node: + type: string + nodeMeta: + additionalProperties: + type: string + type: object + partition: + type: string + service: + properties: + address: + type: string + enableTagOverride: + type: boolean + id: + type: string + locality: + properties: + region: + type: string + zone: + type: string + type: object + meta: + additionalProperties: + type: string + type: object + name: + type: string + namespace: + type: string + partition: + type: string + port: + type: integer + socketPath: + type: string + taggedAddresses: + additionalProperties: + properties: + address: + type: string + port: + type: integer + required: + - address + - port + type: object + type: object + tags: + items: + type: string + type: array + weights: + properties: + passing: + type: integer + warning: + type: integer + required: + - passing + - warning + type: object + required: + - address + - name + - port + type: object + skipNodeUpdate: + type: boolean + taggedAddresses: + additionalProperties: + type: string + type: object + type: object + status: + description: RegistrationStatus defines the observed state of Registration. + 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 + lastSyncedTime: + description: LastSyncedTime is the last time the resource successfully + synced with Consul. + format: date-time + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/control-plane/api/common/common.go b/control-plane/api/common/common.go index 730fd622ac..077be60a9b 100644 --- a/control-plane/api/common/common.go +++ b/control-plane/api/common/common.go @@ -28,6 +28,7 @@ const ( ControlPlaneRequestLimit string = "controlplanerequestlimit" RouteAuthFilter string = "routeauthfilter" GatewayPolicy string = "gatewaypolicy" + Registration string = "registration" // V2 resources. TrafficPermissions string = "trafficpermissions" diff --git a/control-plane/api/v1alpha1/registration_types.go b/control-plane/api/v1alpha1/registration_types.go new file mode 100644 index 0000000000..9ca58e7c23 --- /dev/null +++ b/control-plane/api/v1alpha1/registration_types.go @@ -0,0 +1,303 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package v1alpha1 + +import ( + "errors" + "maps" + "slices" + "time" + + capi "github.com/hashicorp/consul/api" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func init() { + SchemeBuilder.Register(&Registration{}, &RegistrationList{}) +} + +// +genclient +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:subresource:status + +// Registration defines the resource for working with service registrations. +type Registration struct { + // Standard Kubernetes resource metadata. + metav1.TypeMeta `json:",inline"` + + // Standard object's metadata. + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Spec defines the desired state of Registration. + Spec RegistrationSpec `json:"spec,omitempty"` + + Status RegistrationStatus `json:"status,omitempty"` +} + +// RegistrationStatus defines the observed state of Registration. +type RegistrationStatus struct { + // Conditions indicate the latest available observations of a resource's current state. + // +optional + // +patchMergeKey=type + // +patchStrategy=merge + Conditions Conditions `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` + // LastSyncedTime is the last time the resource successfully synced with Consul. + // +optional + LastSyncedTime *metav1.Time `json:"lastSyncedTime,omitempty" description:"last time the condition transitioned from one status to another"` +} + +// +k8s:deepcopy-gen=true + +// RegistrationSpec specifies the desired state of the Config CRD. +type RegistrationSpec struct { + ID string `json:"id,omitempty"` + Node string `json:"node,omitempty"` + Address string `json:"address,omitempty"` + TaggedAddresses map[string]string `json:"taggedAddresses,omitempty"` + NodeMeta map[string]string `json:"nodeMeta,omitempty"` + Datacenter string `json:"datacenter,omitempty"` + Service Service `json:"service,omitempty"` + SkipNodeUpdate bool `json:"skipNodeUpdate,omitempty"` + Partition string `json:"partition,omitempty"` + HealthCheck *HealthCheck `json:"check,omitempty"` + Locality *Locality `json:"locality,omitempty"` +} + +// +k8s:deepcopy-gen=true + +type Service struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Tags []string `json:"tags,omitempty"` + Meta map[string]string `json:"meta,omitempty"` + Port int `json:"port"` + Address string `json:"address"` + SocketPath string `json:"socketPath,omitempty"` + TaggedAddresses map[string]ServiceAddress `json:"taggedAddresses,omitempty"` + Weights Weights `json:"weights,omitempty"` + EnableTagOverride bool `json:"enableTagOverride,omitempty"` + Locality *Locality `json:"locality,omitempty"` + Namespace string `json:"namespace,omitempty"` + Partition string `json:"partition,omitempty"` +} + +// +k8s:deepcopy-gen=true + +type ServiceAddress struct { + Address string `json:"address"` + Port int `json:"port"` +} + +// +k8s:deepcopy-gen=true + +type Weights struct { + Passing int `json:"passing"` + Warning int `json:"warning"` +} + +// +k8s:deepcopy-gen=true + +type Locality struct { + Region string `json:"region,omitempty"` + Zone string `json:"zone,omitempty"` +} + +// +k8s:deepcopy-gen=true + +// HealthCheck is used to represent a single check. +type HealthCheck struct { + Node string `json:"node"` + CheckID string `json:"checkId"` + Name string `json:"name"` + Status string `json:"status"` + Notes string `json:"notes,omitempty"` + Output string `json:"output,omitempty"` + ServiceID string `json:"serviceId"` + ServiceName string `json:"serviceName"` + Type string `json:"type,omitempty"` + ExposedPort int `json:"exposedPort,omitempty"` + Definition HealthCheckDefinition `json:"definition"` + Namespace string `json:"namespace,omitempty"` + Partition string `json:"partition,omitempty"` +} + +// HealthCheckDefinition is used to store the details about +// a health check's execution. +type HealthCheckDefinition struct { + HTTP string `json:"http,omitempty"` + Header map[string][]string `json:"header,omitempty"` + Method string `json:"method,omitempty"` + Body string `json:"body,omitempty"` + TLSServerName string `json:"tlsServerName,omitempty"` + TLSSkipVerify bool `json:"tlsSkipVerify,omitempty"` + TCP string `json:"tcp,omitempty"` + TCPUseTLS bool `json:"tcpUseTLS,omitempty"` + UDP string `json:"udp,omitempty"` + GRPC string `json:"grpc,omitempty"` + OSService string `json:"osService,omitempty"` + GRPCUseTLS bool `json:"grpcUseTLS,omitempty"` + IntervalDuration string `json:"intervalDuration"` + TimeoutDuration string `json:"timeoutDuration"` + DeregisterCriticalServiceAfterDuration string `json:"deregisterCriticalServiceAfterDuration"` +} + +// +kubebuilder:object:root=true + +// RegistrationList is a list of Registration resources. +type RegistrationList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + // Items is the list of Registrations. + Items []Registration `json:"items"` +} + +// ToCatalogRegistration converts a Registration to a Consul CatalogRegistration. +func (r *Registration) ToCatalogRegistration() (*capi.CatalogRegistration, error) { + check, err := copyHealthCheck(r.Spec.HealthCheck) + if err != nil { + return nil, err + } + + return &capi.CatalogRegistration{ + ID: r.Spec.ID, + Node: r.Spec.Node, + Address: r.Spec.Address, + TaggedAddresses: maps.Clone(r.Spec.TaggedAddresses), + NodeMeta: maps.Clone(r.Spec.NodeMeta), + Datacenter: r.Spec.Datacenter, + Service: &capi.AgentService{ + ID: r.Spec.Service.ID, + Service: r.Spec.Service.Name, + Tags: slices.Clone(r.Spec.Service.Tags), + Meta: maps.Clone(r.Spec.Service.Meta), + Port: r.Spec.Service.Port, + Address: r.Spec.Service.Address, + SocketPath: r.Spec.Service.SocketPath, + TaggedAddresses: copyTaggedAddresses(r.Spec.Service.TaggedAddresses), + Weights: capi.AgentWeights(r.Spec.Service.Weights), + EnableTagOverride: r.Spec.Service.EnableTagOverride, + Namespace: r.Spec.Service.Namespace, + Partition: r.Spec.Service.Partition, + Locality: copyLocality(r.Spec.Service.Locality), + }, + Check: check, + SkipNodeUpdate: r.Spec.SkipNodeUpdate, + Partition: r.Spec.Partition, + Locality: copyLocality(r.Spec.Locality), + }, nil +} + +func copyTaggedAddresses(taggedAddresses map[string]ServiceAddress) map[string]capi.ServiceAddress { + if taggedAddresses == nil { + return nil + } + result := make(map[string]capi.ServiceAddress, len(taggedAddresses)) + for k, v := range taggedAddresses { + result[k] = capi.ServiceAddress(v) + } + return result +} + +func copyLocality(locality *Locality) *capi.Locality { + if locality == nil { + return nil + } + return &capi.Locality{ + Region: locality.Region, + Zone: locality.Zone, + } +} + +var ( + ErrInvalidInterval = errors.New("invalid value for IntervalDuration") + ErrInvalidTimeout = errors.New("invalid value for TimeoutDuration") + ErrInvalidDergisterAfter = errors.New("invalid value for DeregisterCriticalServiceAfterDuration") +) + +func copyHealthCheck(healthCheck *HealthCheck) (*capi.AgentCheck, error) { + if healthCheck == nil { + return nil, nil + } + + // TODO: handle error + intervalDuration, err := time.ParseDuration(healthCheck.Definition.IntervalDuration) + if err != nil { + return nil, ErrInvalidInterval + } + + timeoutDuration, err := time.ParseDuration(healthCheck.Definition.TimeoutDuration) + if err != nil { + return nil, ErrInvalidTimeout + } + + deregisterAfter, err := time.ParseDuration(healthCheck.Definition.DeregisterCriticalServiceAfterDuration) + if err != nil { + return nil, ErrInvalidDergisterAfter + } + + return &capi.AgentCheck{ + Node: healthCheck.Node, + Notes: healthCheck.Notes, + ServiceName: healthCheck.ServiceName, + CheckID: healthCheck.CheckID, + Name: healthCheck.Name, + Type: healthCheck.Type, + Status: healthCheck.Status, + ServiceID: healthCheck.ServiceID, + ExposedPort: healthCheck.ExposedPort, + Output: healthCheck.Output, + Namespace: healthCheck.Namespace, + Partition: healthCheck.Partition, + Definition: capi.HealthCheckDefinition{ + HTTP: healthCheck.Definition.HTTP, + TCP: healthCheck.Definition.TCP, + GRPC: healthCheck.Definition.GRPC, + GRPCUseTLS: healthCheck.Definition.GRPCUseTLS, + Method: healthCheck.Definition.Method, + Header: healthCheck.Definition.Header, + Body: healthCheck.Definition.Body, + TLSServerName: healthCheck.Definition.TLSServerName, + TLSSkipVerify: healthCheck.Definition.TLSSkipVerify, + OSService: healthCheck.Definition.OSService, + IntervalDuration: intervalDuration, + TimeoutDuration: timeoutDuration, + DeregisterCriticalServiceAfterDuration: deregisterAfter, + }, + }, nil +} + +// ToCatalogDeregistration converts a Registration to a Consul CatalogDeregistration. +func (r *Registration) ToCatalogDeregistration() *capi.CatalogDeregistration { + checkID := "" + if r.Spec.HealthCheck != nil { + checkID = r.Spec.HealthCheck.CheckID + } + + return &capi.CatalogDeregistration{ + Node: r.Spec.Node, + Address: r.Spec.Address, + Datacenter: r.Spec.Datacenter, + ServiceID: r.Spec.Service.ID, + CheckID: checkID, + Namespace: r.Spec.Service.Namespace, + Partition: r.Spec.Service.Partition, + } +} + +// SetSyncedCondition sets the synced condition on the Registration. +func (r *Registration) SetSyncedCondition(status corev1.ConditionStatus, reason string, message string) { + r.Status.Conditions = Conditions{ + { + Type: ConditionSynced, + Status: status, + LastTransitionTime: metav1.Now(), + Reason: reason, + Message: message, + }, + } +} diff --git a/control-plane/api/v1alpha1/registration_types_test.go b/control-plane/api/v1alpha1/registration_types_test.go new file mode 100644 index 0000000000..8c3744efb9 --- /dev/null +++ b/control-plane/api/v1alpha1/registration_types_test.go @@ -0,0 +1,293 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package v1alpha1 + +import ( + "testing" + "time" + + capi "github.com/hashicorp/consul/api" + + "github.com/stretchr/testify/require" +) + +func TestToCatalogRegistration(tt *testing.T) { + cases := map[string]struct { + registration *Registration + expected *capi.CatalogRegistration + }{ + "minimal registration": { + registration: &Registration{ + Spec: RegistrationSpec{ + ID: "node-id", + Node: "node-virtual", + Address: "127.0.0.1", + Datacenter: "dc1", + Service: Service{ + ID: "service-id", + Name: "service-name", + Port: 8080, + Address: "127.0.0.1", + }, + }, + }, + expected: &capi.CatalogRegistration{ + ID: "node-id", + Node: "node-virtual", + Address: "127.0.0.1", + Datacenter: "dc1", + Service: &capi.AgentService{ + ID: "service-id", + Service: "service-name", + Port: 8080, + Address: "127.0.0.1", + }, + }, + }, + "maximal registration": { + registration: &Registration{ + Spec: RegistrationSpec{ + ID: "node-id", + Node: "node-virtual", + Address: "127.0.0.1", + TaggedAddresses: map[string]string{ + "lan": "8080", + }, + NodeMeta: map[string]string{ + "n1": "m1", + }, + Datacenter: "dc1", + Service: Service{ + ID: "service-id", + Name: "service-name", + Tags: []string{"tag1", "tag2"}, + Meta: map[string]string{ + "m1": "1", + "m2": "2", + }, + Port: 8080, + Address: "127.0.0.1", + TaggedAddresses: map[string]ServiceAddress{ + "lan": { + Address: "10.0.0.10", + Port: 5000, + }, + }, + Weights: Weights{ + Passing: 50, + Warning: 100, + }, + EnableTagOverride: true, + Locality: &Locality{ + Region: "us-east-1", + Zone: "auto", + }, + Namespace: "n1", + Partition: "p1", + }, + Partition: "p1", + HealthCheck: &HealthCheck{ + Node: "node-virtual", + CheckID: "service-check", + Name: "service-health", + Status: "passing", + Notes: "all about that service", + Output: "healthy", + ServiceID: "service-id", + ServiceName: "service-name", + Type: "readiness", + ExposedPort: 19000, + Definition: HealthCheckDefinition{ + HTTP: "/health", + TCP: "tcp-check", + Header: map[string][]string{ + "Content-Type": {"application/json"}, + }, + Method: "GET", + TLSServerName: "my-secure-tls-server", + TLSSkipVerify: true, + Body: "some-body", + GRPC: "/grpc-health-check", + GRPCUseTLS: true, + OSService: "osservice-name", + IntervalDuration: "5s", + TimeoutDuration: "10s", + DeregisterCriticalServiceAfterDuration: "30s", + }, + Namespace: "n1", + Partition: "p1", + }, + Locality: &Locality{ + Region: "us-east-1", + Zone: "auto", + }, + }, + }, + expected: &capi.CatalogRegistration{ + ID: "node-id", + Node: "node-virtual", + Address: "127.0.0.1", + TaggedAddresses: map[string]string{ + "lan": "8080", + }, + NodeMeta: map[string]string{ + "n1": "m1", + }, + Datacenter: "dc1", + Service: &capi.AgentService{ + ID: "service-id", + Service: "service-name", + Tags: []string{"tag1", "tag2"}, + Meta: map[string]string{ + "m1": "1", + "m2": "2", + }, + Port: 8080, + Address: "127.0.0.1", + TaggedAddresses: map[string]capi.ServiceAddress{ + "lan": { + Address: "10.0.0.10", + Port: 5000, + }, + }, + Weights: capi.AgentWeights{ + Passing: 50, + Warning: 100, + }, + EnableTagOverride: true, + Locality: &capi.Locality{ + Region: "us-east-1", + Zone: "auto", + }, + Namespace: "n1", + Partition: "p1", + }, + Check: &capi.AgentCheck{ + Node: "node-virtual", + CheckID: "service-check", + Name: "service-health", + Status: "passing", + Notes: "all about that service", + Output: "healthy", + ServiceID: "service-id", + ServiceName: "service-name", + Type: "readiness", + ExposedPort: 19000, + Definition: capi.HealthCheckDefinition{ + HTTP: "/health", + TCP: "tcp-check", + Header: map[string][]string{ + "Content-Type": {"application/json"}, + }, + Method: "GET", + TLSServerName: "my-secure-tls-server", + TLSSkipVerify: true, + Body: "some-body", + GRPC: "/grpc-health-check", + GRPCUseTLS: true, + OSService: "osservice-name", + IntervalDuration: toDuration(tt, "5s"), + TimeoutDuration: toDuration(tt, "10s"), + DeregisterCriticalServiceAfterDuration: toDuration(tt, "30s"), + }, + Namespace: "n1", + Partition: "p1", + }, + SkipNodeUpdate: false, + Partition: "p1", + Locality: &capi.Locality{ + Region: "us-east-1", + Zone: "auto", + }, + }, + }, + } + + for name, tc := range cases { + tc := tc + tt.Run(name, func(t *testing.T) { + t.Parallel() + actual, err := tc.registration.ToCatalogRegistration() + require.NoError(t, err) + require.Equal(t, tc.expected, actual) + }) + } +} + +func TestToCatalogDeregistration(tt *testing.T) { + cases := map[string]struct { + registration *Registration + expected *capi.CatalogDeregistration + }{ + "with health check": { + registration: &Registration{ + Spec: RegistrationSpec{ + ID: "node-id", + Node: "node-virtual", + Address: "127.0.0.1", + Datacenter: "dc1", + Service: Service{ + ID: "service-id", + Namespace: "n1", + Partition: "p1", + }, + HealthCheck: &HealthCheck{ + CheckID: "checkID", + }, + }, + }, + expected: &capi.CatalogDeregistration{ + Node: "node-virtual", + Address: "127.0.0.1", + Datacenter: "dc1", + ServiceID: "service-id", + CheckID: "checkID", + Namespace: "n1", + Partition: "p1", + }, + }, + "no health check": { + registration: &Registration{ + Spec: RegistrationSpec{ + ID: "node-id", + Node: "node-virtual", + Address: "127.0.0.1", + Datacenter: "dc1", + Service: Service{ + ID: "service-id", + Namespace: "n1", + Partition: "p1", + }, + }, + }, + expected: &capi.CatalogDeregistration{ + Node: "node-virtual", + Address: "127.0.0.1", + Datacenter: "dc1", + ServiceID: "service-id", + CheckID: "", + Namespace: "n1", + Partition: "p1", + }, + }, + } + + for name, tc := range cases { + tc := tc + tt.Run(name, func(t *testing.T) { + t.Parallel() + actual := tc.registration.ToCatalogDeregistration() + require.Equal(t, tc.expected, actual) + }) + } +} + +func toDuration(t *testing.T, d string) time.Duration { + t.Helper() + duration, err := time.ParseDuration(d) + if err != nil { + t.Fatal(err) + } + return duration +} diff --git a/control-plane/api/v1alpha1/zz_generated.deepcopy.go b/control-plane/api/v1alpha1/zz_generated.deepcopy.go index 2a1854d178..a8aed9b1ff 100644 --- a/control-plane/api/v1alpha1/zz_generated.deepcopy.go +++ b/control-plane/api/v1alpha1/zz_generated.deepcopy.go @@ -881,6 +881,52 @@ func (in *HashPolicy) DeepCopy() *HashPolicy { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HealthCheck) DeepCopyInto(out *HealthCheck) { + *out = *in + in.Definition.DeepCopyInto(&out.Definition) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HealthCheck. +func (in *HealthCheck) DeepCopy() *HealthCheck { + if in == nil { + return nil + } + out := new(HealthCheck) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HealthCheckDefinition) DeepCopyInto(out *HealthCheckDefinition) { + *out = *in + if in.Header != nil { + in, out := &in.Header, &out.Header + *out = make(map[string][]string, len(*in)) + for key, val := range *in { + var outVal []string + if val == nil { + (*out)[key] = nil + } else { + in, out := &val, &outVal + *out = make([]string, len(*in)) + copy(*out, *in) + } + (*out)[key] = outVal + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HealthCheckDefinition. +func (in *HealthCheckDefinition) DeepCopy() *HealthCheckDefinition { + if in == nil { + return nil + } + out := new(HealthCheckDefinition) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IngressGateway) DeepCopyInto(out *IngressGateway) { *out = *in @@ -1735,6 +1781,21 @@ func (in *LocalJWKS) DeepCopy() *LocalJWKS { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Locality) DeepCopyInto(out *Locality) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Locality. +func (in *Locality) DeepCopy() *Locality { + if in == nil { + return nil + } + out := new(Locality) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Mesh) DeepCopyInto(out *Mesh) { *out = *in @@ -2483,6 +2544,131 @@ func (in *ReadWriteRatesConfig) DeepCopy() *ReadWriteRatesConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Registration) DeepCopyInto(out *Registration) { + *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 Registration. +func (in *Registration) DeepCopy() *Registration { + if in == nil { + return nil + } + out := new(Registration) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Registration) 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 *RegistrationList) DeepCopyInto(out *RegistrationList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Registration, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegistrationList. +func (in *RegistrationList) DeepCopy() *RegistrationList { + if in == nil { + return nil + } + out := new(RegistrationList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RegistrationList) 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 *RegistrationSpec) DeepCopyInto(out *RegistrationSpec) { + *out = *in + if in.TaggedAddresses != nil { + in, out := &in.TaggedAddresses, &out.TaggedAddresses + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.NodeMeta != nil { + in, out := &in.NodeMeta, &out.NodeMeta + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + in.Service.DeepCopyInto(&out.Service) + if in.HealthCheck != nil { + in, out := &in.HealthCheck, &out.HealthCheck + *out = new(HealthCheck) + (*in).DeepCopyInto(*out) + } + if in.Locality != nil { + in, out := &in.Locality, &out.Locality + *out = new(Locality) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegistrationSpec. +func (in *RegistrationSpec) DeepCopy() *RegistrationSpec { + if in == nil { + return nil + } + out := new(RegistrationSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RegistrationStatus) DeepCopyInto(out *RegistrationStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(Conditions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.LastSyncedTime != nil { + in, out := &in.LastSyncedTime, &out.LastSyncedTime + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegistrationStatus. +func (in *RegistrationStatus) DeepCopy() *RegistrationStatus { + if in == nil { + return nil + } + out := new(RegistrationStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RemoteJWKS) DeepCopyInto(out *RemoteJWKS) { *out = *in @@ -2956,6 +3142,61 @@ func (in *SecretRefStatus) DeepCopy() *SecretRefStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Service) DeepCopyInto(out *Service) { + *out = *in + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Meta != nil { + in, out := &in.Meta, &out.Meta + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.TaggedAddresses != nil { + in, out := &in.TaggedAddresses, &out.TaggedAddresses + *out = make(map[string]ServiceAddress, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + out.Weights = in.Weights + if in.Locality != nil { + in, out := &in.Locality, &out.Locality + *out = new(Locality) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Service. +func (in *Service) DeepCopy() *Service { + if in == nil { + return nil + } + out := new(Service) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceAddress) DeepCopyInto(out *ServiceAddress) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceAddress. +func (in *ServiceAddress) DeepCopy() *ServiceAddress { + if in == nil { + return nil + } + out := new(ServiceAddress) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceConsumer) DeepCopyInto(out *ServiceConsumer) { *out = *in @@ -4034,3 +4275,18 @@ func (in *Upstreams) DeepCopy() *Upstreams { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Weights) DeepCopyInto(out *Weights) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Weights. +func (in *Weights) DeepCopy() *Weights { + if in == nil { + return nil + } + out := new(Weights) + in.DeepCopyInto(out) + return out +} diff --git a/control-plane/config/crd/bases/consul.hashicorp.com_registrations.yaml b/control-plane/config/crd/bases/consul.hashicorp.com_registrations.yaml new file mode 100644 index 0000000000..41b11ae569 --- /dev/null +++ b/control-plane/config/crd/bases/consul.hashicorp.com_registrations.yaml @@ -0,0 +1,249 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.12.1 + name: registrations.consul.hashicorp.com +spec: + group: consul.hashicorp.com + names: + kind: Registration + listKind: RegistrationList + plural: registrations + singular: registration + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: Registration defines the resource for working with service registrations. + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: Spec defines the desired state of Registration. + properties: + address: + type: string + check: + description: HealthCheck is used to represent a single check. + properties: + checkId: + type: string + definition: + description: HealthCheckDefinition is used to store the details + about a health check's execution. + properties: + body: + type: string + deregisterCriticalServiceAfterDuration: + type: string + grpc: + type: string + grpcUseTLS: + type: boolean + header: + additionalProperties: + items: + type: string + type: array + type: object + http: + type: string + intervalDuration: + type: string + method: + type: string + osService: + type: string + tcp: + type: string + tcpUseTLS: + type: boolean + timeoutDuration: + type: string + tlsServerName: + type: string + tlsSkipVerify: + type: boolean + udp: + type: string + required: + - deregisterCriticalServiceAfterDuration + - intervalDuration + - timeoutDuration + type: object + exposedPort: + type: integer + name: + type: string + namespace: + type: string + node: + type: string + notes: + type: string + output: + type: string + partition: + type: string + serviceId: + type: string + serviceName: + type: string + status: + type: string + type: + type: string + required: + - checkId + - definition + - name + - node + - serviceId + - serviceName + - status + type: object + datacenter: + type: string + id: + type: string + locality: + properties: + region: + type: string + zone: + type: string + type: object + node: + type: string + nodeMeta: + additionalProperties: + type: string + type: object + partition: + type: string + service: + properties: + address: + type: string + enableTagOverride: + type: boolean + id: + type: string + locality: + properties: + region: + type: string + zone: + type: string + type: object + meta: + additionalProperties: + type: string + type: object + name: + type: string + namespace: + type: string + partition: + type: string + port: + type: integer + socketPath: + type: string + taggedAddresses: + additionalProperties: + properties: + address: + type: string + port: + type: integer + required: + - address + - port + type: object + type: object + tags: + items: + type: string + type: array + weights: + properties: + passing: + type: integer + warning: + type: integer + required: + - passing + - warning + type: object + required: + - address + - name + - port + type: object + skipNodeUpdate: + type: boolean + taggedAddresses: + additionalProperties: + type: string + type: object + type: object + status: + description: RegistrationStatus defines the observed state of Registration. + 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 + lastSyncedTime: + description: LastSyncedTime is the last time the resource successfully + synced with Consul. + format: date-time + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/control-plane/controllers/configentries/registrations_controller.go b/control-plane/controllers/configentries/registrations_controller.go new file mode 100644 index 0000000000..c9137ff63a --- /dev/null +++ b/control-plane/controllers/configentries/registrations_controller.go @@ -0,0 +1,165 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package configentries + +import ( + "context" + "time" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + k8serrors "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" + + capi "github.com/hashicorp/consul/api" + + "github.com/hashicorp/consul-k8s/control-plane/api/v1alpha1" + "github.com/hashicorp/consul-k8s/control-plane/consul" +) + +const RegistrationFinalizer = "registration.finalizers.consul.hashicorp.com" + +// RegistrationsController is the controller for Registrations resources. +type RegistrationsController struct { + client.Client + FinalizerPatcher + Log logr.Logger + Scheme *runtime.Scheme + ConsulClientConfig *consul.Config + ConsulServerConnMgr consul.ServerConnectionManager +} + +// +kubebuilder:rbac:groups=consul.hashicorp.com,resources=servicerouters,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=consul.hashicorp.com,resources=servicerouters/status,verbs=get;update;patch + +func (r *RegistrationsController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := r.Log.V(1).WithValues("registration", req.NamespacedName) + log.Info("Reconciling Registaration") + + registration := &v1alpha1.Registration{} + // get the registration + if err := r.Client.Get(ctx, req.NamespacedName, registration); err != nil { + if !k8serrors.IsNotFound(err) { + log.Error(err, "unable to get registration") + } + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + client, err := consul.NewClientFromConnMgr(r.ConsulClientConfig, r.ConsulServerConnMgr) + if err != nil { + log.Error(err, "error initializing consul client") + return ctrl.Result{}, err + } + + // deletion request + if !registration.ObjectMeta.DeletionTimestamp.IsZero() { + log.Info("Deregistering service") + err = r.deregisterService(ctx, log, client, registration) + if err != nil { + r.updateStatusError(ctx, registration, "ConsulErrorDeregistration", err) + return ctrl.Result{}, err + } + err := r.updateStatus(ctx, req.NamespacedName) + if err != nil { + log.Error(err, "failed to update status") + } + + return ctrl.Result{}, nil + } + + log.Info("Registering service") + err = r.registerService(ctx, log, client, registration) + if err != nil { + r.updateStatusError(ctx, registration, "ConsulErrorRegistration", err) + return ctrl.Result{}, err + } + + err = r.updateStatus(ctx, req.NamespacedName) + if err != nil { + log.Error(err, "failed to update status") + } + return ctrl.Result{}, err +} + +func (r *RegistrationsController) registerService(ctx context.Context, log logr.Logger, client *capi.Client, registration *v1alpha1.Registration) error { + patch := r.AddFinalizersPatch(registration, RegistrationFinalizer) + + err := r.Patch(ctx, registration, patch) + if err != nil { + return err + } + + regReq, err := registration.ToCatalogRegistration() + if err != nil { + return err + } + + _, err = client.Catalog().Register(regReq, nil) + if err != nil { + log.Error(err, "error registering service", "svcName", regReq.Service.Service) + return err + } + + log.Info("Successfully registered service", "svcName", regReq.Service.Service) + return nil +} + +func (r *RegistrationsController) deregisterService(ctx context.Context, log logr.Logger, client *capi.Client, registration *v1alpha1.Registration) error { + deRegReq := registration.ToCatalogDeregistration() + + _, err := client.Catalog().Deregister(deRegReq, nil) + if err != nil { + log.Error(err, "error deregistering service", "svcID", deRegReq.ServiceID) + return err + } + + patch := r.RemoveFinalizersPatch(registration, RegistrationFinalizer) + + if err := r.Patch(ctx, registration, patch); err != nil { + return err + } + log.Info("Successfully deregistered service", "svcID", deRegReq.ServiceID) + return nil +} + +func (r *RegistrationsController) Logger(name types.NamespacedName) logr.Logger { + return r.Log.WithValues("request", name) +} + +func (r *RegistrationsController) updateStatusError(ctx context.Context, registration *v1alpha1.Registration, reason string, reconcileErr error) { + registration.SetSyncedCondition(corev1.ConditionFalse, reason, reconcileErr.Error()) + registration.Status.LastSyncedTime = &metav1.Time{Time: time.Now()} + + err := r.Status().Update(ctx, registration) + if err != nil { + r.Log.Error(err, "failed to update Registration status", "name", registration.Name, "namespace", registration.Namespace) + } +} + +func (r *RegistrationsController) updateStatus(ctx context.Context, req types.NamespacedName) error { + registration := &v1alpha1.Registration{} + + err := r.Get(ctx, req, registration) + if err != nil { + return err + } + + registration.Status.LastSyncedTime = &metav1.Time{Time: time.Now()} + registration.SetSyncedCondition(corev1.ConditionTrue, "", "") + + err = r.Status().Update(ctx, registration) + if err != nil { + r.Log.Error(err, "failed to update Registration status", "name", registration.Name, "namespace", registration.Namespace) + return err + } + return nil +} + +func (r *RegistrationsController) SetupWithManager(mgr ctrl.Manager) error { + return setupWithManager(mgr, &v1alpha1.Registration{}, r) +} diff --git a/control-plane/controllers/configentries/registrations_controller_test.go b/control-plane/controllers/configentries/registrations_controller_test.go new file mode 100644 index 0000000000..5e2b2721fa --- /dev/null +++ b/control-plane/controllers/configentries/registrations_controller_test.go @@ -0,0 +1,281 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package configentries_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + + logrtest "github.com/go-logr/logr/testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stretchr/testify/require" + v1 "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/fake" + + capi "github.com/hashicorp/consul/api" + + "github.com/hashicorp/consul-k8s/control-plane/api/v1alpha1" + "github.com/hashicorp/consul-k8s/control-plane/consul" + "github.com/hashicorp/consul-k8s/control-plane/controllers/configentries" + "github.com/hashicorp/consul-k8s/control-plane/helper/test" +) + +func TestReconcile_Success(tt *testing.T) { + deletionTime := metav1.Now() + cases := map[string]struct { + registration *v1alpha1.Registration + expectedConditions []v1alpha1.Condition + }{ + "success on registration": { + registration: &v1alpha1.Registration{ + TypeMeta: metav1.TypeMeta{ + Kind: "Registration", + APIVersion: "consul.hashicorp.com/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-registration", + Finalizers: []string{configentries.RegistrationFinalizer}, + }, + Spec: v1alpha1.RegistrationSpec{ + ID: "node-id", + Node: "virtual-node", + Address: "127.0.0.1", + Datacenter: "dc1", + Service: v1alpha1.Service{ + ID: "service-id", + Name: "service-name", + Port: 8080, + Address: "127.0.0.1", + }, + }, + }, + expectedConditions: []v1alpha1.Condition{{ + Type: "Synced", + Status: v1.ConditionTrue, + Reason: "", + Message: "", + }}, + }, + "success on deregistration": { + registration: &v1alpha1.Registration{ + TypeMeta: metav1.TypeMeta{ + Kind: "Registration", + APIVersion: "consul.hashicorp.com/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-registration", + Finalizers: []string{configentries.RegistrationFinalizer}, + DeletionTimestamp: &deletionTime, + }, + Spec: v1alpha1.RegistrationSpec{ + ID: "node-id", + Node: "virtual-node", + Address: "127.0.0.1", + Datacenter: "dc1", + Service: v1alpha1.Service{ + ID: "service-id", + Name: "service-name", + Port: 8080, + Address: "127.0.0.1", + }, + }, + }, + expectedConditions: []v1alpha1.Condition{}, + }, + } + + for name, tc := range cases { + tc := tc + tt.Run(name, func(t *testing.T) { + t.Parallel() + s := runtime.NewScheme() + s.AddKnownTypes(v1alpha1.GroupVersion, &v1alpha1.Registration{}) + ctx := context.Background() + + consulServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer consulServer.Close() + + parsedURL, err := url.Parse(consulServer.URL) + require.NoError(t, err) + host := strings.Split(parsedURL.Host, ":")[0] + + port, err := strconv.Atoi(parsedURL.Port()) + require.NoError(t, err) + + testClient := &test.TestServerClient{ + Cfg: &consul.Config{APIClientConfig: &capi.Config{Address: host}, HTTPPort: port}, + Watcher: test.MockConnMgrForIPAndPort(t, host, port, false), + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithRuntimeObjects(tc.registration). + WithStatusSubresource(&v1alpha1.Registration{}). + Build() + + controller := &configentries.RegistrationsController{ + Client: fakeClient, + Log: logrtest.NewTestLogger(t), + Scheme: s, + ConsulClientConfig: testClient.Cfg, + ConsulServerConnMgr: testClient.Watcher, + } + + _, err = controller.Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{Name: tc.registration.Name, Namespace: tc.registration.Namespace}, + }) + require.NoError(t, err) + + fetchedReg := &v1alpha1.Registration{TypeMeta: metav1.TypeMeta{APIVersion: "consul.hashicorp.com/v1alpha1", Kind: "Registration"}} + fakeClient.Get(ctx, types.NamespacedName{Name: tc.registration.Name}, fetchedReg) + + require.Len(t, fetchedReg.Status.Conditions, len(tc.expectedConditions)) + + for i, c := range fetchedReg.Status.Conditions { + if diff := cmp.Diff(c, tc.expectedConditions[i], cmpopts.IgnoreFields(v1alpha1.Condition{}, "LastTransitionTime", "Message")); diff != "" { + t.Errorf("unexpected condition diff: %s", diff) + } + } + }) + } +} + +func TestReconcile_Failure(tt *testing.T) { + deletionTime := metav1.Now() + cases := map[string]struct { + registration *v1alpha1.Registration + expectedConditions []v1alpha1.Condition + }{ + "failure on registration": { + registration: &v1alpha1.Registration{ + TypeMeta: metav1.TypeMeta{ + Kind: "Registration", + APIVersion: "consul.hashicorp.com/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-registration", + Finalizers: []string{configentries.RegistrationFinalizer}, + }, + Spec: v1alpha1.RegistrationSpec{ + ID: "node-id", + Node: "virtual-node", + Address: "127.0.0.1", + Datacenter: "dc1", + Service: v1alpha1.Service{ + ID: "service-id", + Name: "service-name", + Port: 8080, + Address: "127.0.0.1", + }, + }, + }, + expectedConditions: []v1alpha1.Condition{{ + Type: "Synced", + Status: v1.ConditionFalse, + Reason: "ConsulErrorRegistration", + Message: "", + }}, + }, + "failure on deregistration": { + registration: &v1alpha1.Registration{ + TypeMeta: metav1.TypeMeta{ + Kind: "Registration", + APIVersion: "consul.hashicorp.com/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-registration", + Finalizers: []string{configentries.RegistrationFinalizer}, + DeletionTimestamp: &deletionTime, + }, + Spec: v1alpha1.RegistrationSpec{ + ID: "node-id", + Node: "virtual-node", + Address: "127.0.0.1", + Datacenter: "dc1", + Service: v1alpha1.Service{ + ID: "service-id", + Name: "service-name", + Port: 8080, + Address: "127.0.0.1", + }, + }, + }, + expectedConditions: []v1alpha1.Condition{{ + Type: "Synced", + Status: v1.ConditionFalse, + Reason: "ConsulErrorDeregistration", + Message: "", + }}, + }, + } + + for name, tc := range cases { + tc := tc + tt.Run(name, func(t *testing.T) { + t.Parallel() + s := runtime.NewScheme() + s.AddKnownTypes(v1alpha1.GroupVersion, &v1alpha1.Registration{}) + ctx := context.Background() + + consulServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + })) + defer consulServer.Close() + + parsedURL, err := url.Parse(consulServer.URL) + require.NoError(t, err) + host := strings.Split(parsedURL.Host, ":")[0] + + port, err := strconv.Atoi(parsedURL.Port()) + require.NoError(t, err) + + testClient := &test.TestServerClient{ + Cfg: &consul.Config{APIClientConfig: &capi.Config{Address: host}, HTTPPort: port}, + Watcher: test.MockConnMgrForIPAndPort(t, host, port, false), + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithRuntimeObjects(tc.registration). + WithStatusSubresource(&v1alpha1.Registration{}). + Build() + + controller := &configentries.RegistrationsController{ + Client: fakeClient, + Log: logrtest.NewTestLogger(t), + Scheme: s, + ConsulClientConfig: testClient.Cfg, + ConsulServerConnMgr: testClient.Watcher, + } + + _, err = controller.Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{Name: tc.registration.Name, Namespace: tc.registration.Namespace}, + }) + require.Error(t, err) + + fetchedReg := &v1alpha1.Registration{TypeMeta: metav1.TypeMeta{APIVersion: "consul.hashicorp.com/v1alpha1", Kind: "Registration"}} + fakeClient.Get(ctx, types.NamespacedName{Name: tc.registration.Name}, fetchedReg) + + require.Len(t, fetchedReg.Status.Conditions, len(tc.expectedConditions)) + + for i, c := range fetchedReg.Status.Conditions { + if diff := cmp.Diff(c, tc.expectedConditions[i], cmpopts.IgnoreFields(v1alpha1.Condition{}, "LastTransitionTime", "Message")); diff != "" { + t.Errorf("unexpected condition diff: %s", diff) + } + } + }) + } +} diff --git a/control-plane/subcommand/inject-connect/v1controllers.go b/control-plane/subcommand/inject-connect/v1controllers.go index b90b220bb7..4b1de1e325 100644 --- a/control-plane/subcommand/inject-connect/v1controllers.go +++ b/control-plane/subcommand/inject-connect/v1controllers.go @@ -284,6 +284,17 @@ func (c *Command) configureV1Controllers(ctx context.Context, mgr manager.Manage return err } + if err := (&controllers.RegistrationsController{ + Client: mgr.GetClient(), + ConsulClientConfig: consulConfig, + ConsulServerConnMgr: watcher, + Scheme: mgr.GetScheme(), + Log: ctrl.Log.WithName("controller").WithName(apicommon.Registration), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", apicommon.Registration) + return err + } + if err := mgr.AddReadyzCheck("ready", webhook.ReadinessCheck{CertDir: c.flagCertDir}.Ready); err != nil { setupLog.Error(err, "unable to create readiness check", "controller", endpoints.Controller{}) return err