From da3fb30292f83c2172547b9928b5f1724f3a52ed Mon Sep 17 00:00:00 2001 From: Luke Kysow <1034429+lkysow@users.noreply.github.com> Date: Thu, 26 Nov 2020 14:29:33 -0800 Subject: [PATCH] Add support for ingress gateway CRD - Also update controller-runtime to 0.4.1 --- CHANGELOG.md | 3 + PROJECT | 23 +- api/common/common.go | 1 + api/v1alpha1/ingressgateway_types.go | 308 ++++++++++ api/v1alpha1/ingressgateway_types_test.go | 559 ++++++++++++++++++ api/v1alpha1/ingressgateway_webhook.go | 72 +++ api/v1alpha1/servicerouter_types.go | 18 +- api/v1alpha1/zz_generated.deepcopy.go | 139 +++++ .../consul.hashicorp.com_ingressgateways.yaml | 125 ++++ config/crd/kustomization.yaml | 3 + .../cainjection_in_ingressgateways.yaml | 8 + .../patches/webhook_in_ingressgateways.yaml | 17 + config/rbac/ingressgateway_editor_role.yaml | 24 + config/rbac/ingressgateway_viewer_role.yaml | 20 + config/rbac/role.yaml | 20 + .../consul_v1alpha1_ingressgateway.yaml | 12 + config/webhook/manifests.v1beta1.yaml | 18 + controller/configentry_controller_test.go | 267 +++++++++ controller/ingressgateway_controller.go | 42 ++ subcommand/controller/command.go | 17 + 20 files changed, 1686 insertions(+), 10 deletions(-) create mode 100644 api/v1alpha1/ingressgateway_types.go create mode 100644 api/v1alpha1/ingressgateway_types_test.go create mode 100644 api/v1alpha1/ingressgateway_webhook.go create mode 100644 config/crd/bases/consul.hashicorp.com_ingressgateways.yaml create mode 100644 config/crd/patches/cainjection_in_ingressgateways.yaml create mode 100644 config/crd/patches/webhook_in_ingressgateways.yaml create mode 100644 config/rbac/ingressgateway_editor_role.yaml create mode 100644 config/rbac/ingressgateway_viewer_role.yaml create mode 100644 config/samples/consul_v1alpha1_ingressgateway.yaml create mode 100644 controller/ingressgateway_controller.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 24ad2aeb1d..2bb8203b19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ BUG FIXES: BREAKING CHANGES: * Connect: the flags `-envoy-image` and `-consul-image` for command `inject-connect` are now required. [[GH-405](https://github.com/hashicorp/consul-k8s/pull/405)] +FEATURES: +* CRDs: add new CRD `IngressGateway` for configuring Consul's [ingress-gateway](https://www.consul.io/docs/agent/config-entries/ingress-gateway) config entry. [[GH-407](https://github.com/hashicorp/consul-k8s/pull/407)] + ## 0.21.0 (November 25, 2020) IMPROVEMENTS: diff --git a/PROJECT b/PROJECT index 4ebb45c34e..05427d5ab3 100644 --- a/PROJECT +++ b/PROJECT @@ -1,7 +1,28 @@ -# this is a generated file used for operator sdk during code generation of CRDs, Controllers and webhooks domain: hashicorp.com layout: go.kubebuilder.io/v2 repo: github.com/hashicorp/consul-k8s +resources: +- group: consul + kind: IngressGateway + version: v1alpha1 +- group: consul + kind: ProxyDefaults + version: v1alpha1 +- group: consul + kind: ServiceIntentions + version: v1alpha1 +- group: consul + kind: ServiceDefaults + version: v1alpha1 +- group: consul + kind: ServiceResolver + version: v1alpha1 +- group: consul + kind: ServiceRouter + version: v1alpha1 +- group: consul + kind: ServiceSplitter + version: v1alpha1 version: 3-alpha plugins: go.operator-sdk.io/v2-alpha: {} diff --git a/api/common/common.go b/api/common/common.go index e7c94637a8..35afc73f45 100644 --- a/api/common/common.go +++ b/api/common/common.go @@ -8,6 +8,7 @@ const ( ServiceRouter string = "servicerouter" ServiceSplitter string = "servicesplitter" ServiceIntentions string = "serviceintentions" + IngressGateway string = "ingressgateway" Global string = "global" DefaultConsulNamespace string = "default" diff --git a/api/v1alpha1/ingressgateway_types.go b/api/v1alpha1/ingressgateway_types.go new file mode 100644 index 0000000000..39c4110a05 --- /dev/null +++ b/api/v1alpha1/ingressgateway_types.go @@ -0,0 +1,308 @@ +package v1alpha1 + +import ( + "encoding/json" + "fmt" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + 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 ( + ingressGatewayKubeKind = "ingressgateway" + wildcardServiceName = "*" +) + +func init() { + SchemeBuilder.Register(&IngressGateway{}, &IngressGatewayList{}) +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// IngressGateway is the Schema for the ingressgateways API +// +kubebuilder:printcolumn:name="Synced",type="string",JSONPath=".status.conditions[?(@.type==\"Synced\")].status",description="The sync status of the resource with Consul" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="The age of the resource" +type IngressGateway struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec IngressGatewaySpec `json:"spec,omitempty"` + Status `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// IngressGatewayList contains a list of IngressGateway +type IngressGatewayList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []IngressGateway `json:"items"` +} + +// IngressGatewaySpec defines the desired state of IngressGateway +type IngressGatewaySpec struct { + // TLS holds the TLS configuration for this gateway. + TLS GatewayTLSConfig `json:"tls,omitempty"` + // Listeners declares what ports the ingress gateway should listen on, and + // what services to associated to those ports. + Listeners []IngressListener `json:"listeners,omitempty"` +} + +type GatewayTLSConfig struct { + // Indicates that TLS should be enabled for this gateway service. + Enabled bool `json:"enabled"` +} + +// IngressListener manages the configuration for a listener on a specific port. +type IngressListener struct { + // Port declares the port on which the ingress gateway should listen for traffic. + Port int `json:"port,omitempty"` + + // Protocol declares what type of traffic this listener is expected to + // receive. Depending on the protocol, a listener might support multiplexing + // services over a single port, or additional discovery chain features. The + // current supported values are: (tcp | http | http2 | grpc). + Protocol string `json:"protocol,omitempty"` + + // Services declares the set of services to which the listener forwards + // traffic. + // + // For "tcp" protocol listeners, only a single service is allowed. + // For "http" listeners, multiple services can be declared. + Services []IngressService `json:"services,omitempty"` +} + +// IngressService manages configuration for services that are exposed to +// ingress traffic. +type IngressService struct { + // Name declares the service to which traffic should be forwarded. + // + // This can either be a specific service, or the wildcard specifier, + // "*". If the wildcard specifier is provided, the listener must be of "http" + // protocol and means that the listener will forward traffic to all services. + // + // A name can be specified on multiple listeners, and will be exposed on both + // of the listeners. + Name string `json:"name,omitempty"` + + // Hosts is a list of hostnames which should be associated to this service on + // the defined listener. Only allowed on layer 7 protocols, this will be used + // to route traffic to the service by matching the Host header of the HTTP + // request. + // + // If a host is provided for a service that also has a wildcard specifier + // defined, the host will override the wildcard-specifier-provided + // ".*" domain for that listener. + // + // This cannot be specified when using the wildcard specifier, "*", or when + // using a "tcp" listener. + Hosts []string `json:"hosts,omitempty"` + + // Namespace is the namespace where the service is located. + // Namespacing is a Consul Enterprise feature. + Namespace string `json:"namespace,omitempty"` +} + +func (in *IngressGateway) GetObjectMeta() metav1.ObjectMeta { + return in.ObjectMeta +} + +func (in *IngressGateway) AddFinalizer(name string) { + in.ObjectMeta.Finalizers = append(in.Finalizers(), name) +} + +func (in *IngressGateway) RemoveFinalizer(name string) { + var newFinalizers []string + for _, oldF := range in.Finalizers() { + if oldF != name { + newFinalizers = append(newFinalizers, oldF) + } + } + in.ObjectMeta.Finalizers = newFinalizers +} + +func (in *IngressGateway) Finalizers() []string { + return in.ObjectMeta.Finalizers +} + +func (in *IngressGateway) ConsulKind() string { + return capi.IngressGateway +} + +func (in *IngressGateway) ConsulGlobalResource() bool { + return false +} + +func (in *IngressGateway) ConsulMirroringNS() string { + return in.Namespace +} + +func (in *IngressGateway) KubeKind() string { + return ingressGatewayKubeKind +} + +func (in *IngressGateway) ConsulName() string { + return in.ObjectMeta.Name +} + +func (in *IngressGateway) KubernetesName() string { + return in.ObjectMeta.Name +} + +func (in *IngressGateway) SetSyncedCondition(status corev1.ConditionStatus, reason, message string) { + in.Status.Conditions = Conditions{ + { + Type: ConditionSynced, + Status: status, + LastTransitionTime: metav1.Now(), + Reason: reason, + Message: message, + }, + } +} + +func (in *IngressGateway) SyncedCondition() (status corev1.ConditionStatus, reason, message string) { + cond := in.Status.GetCondition(ConditionSynced) + if cond == nil { + return corev1.ConditionUnknown, "", "" + } + return cond.Status, cond.Reason, cond.Message +} + +func (in *IngressGateway) SyncedConditionStatus() corev1.ConditionStatus { + condition := in.Status.GetCondition(ConditionSynced) + if condition == nil { + return corev1.ConditionUnknown + } + return condition.Status +} + +func (in *IngressGateway) ToConsul(datacenter string) capi.ConfigEntry { + var listeners []capi.IngressListener + for _, l := range in.Spec.Listeners { + listeners = append(listeners, l.toConsul()) + } + return &capi.IngressGatewayConfigEntry{ + Kind: in.ConsulKind(), + Name: in.ConsulName(), + TLS: in.Spec.TLS.toConsul(), + Listeners: listeners, + Meta: meta(datacenter), + } +} + +func (in *IngressGateway) MatchesConsul(candidate capi.ConfigEntry) bool { + configEntry, ok := candidate.(*capi.IngressGatewayConfigEntry) + if !ok { + return false + } + // No datacenter is passed to ToConsul as we ignore the Meta field when checking for equality. + return cmp.Equal(in.ToConsul(""), configEntry, cmpopts.IgnoreFields(capi.IngressGatewayConfigEntry{}, "Namespace", "Meta", "ModifyIndex", "CreateIndex"), cmpopts.IgnoreUnexported(), cmpopts.EquateEmpty()) +} + +func (in *IngressGateway) Validate(namespacesEnabled bool) error { + var errs field.ErrorList + path := field.NewPath("spec") + + for i, v := range in.Spec.Listeners { + errs = append(errs, v.validate(path.Child("listeners").Index(i))...) + } + + errs = append(errs, in.validateNamespaces(namespacesEnabled)...) + + if len(errs) > 0 { + return apierrors.NewInvalid( + schema.GroupKind{Group: ConsulHashicorpGroup, Kind: ingressGatewayKubeKind}, + in.KubernetesName(), errs) + } + return nil +} + +func (in GatewayTLSConfig) toConsul() capi.GatewayTLSConfig { + return capi.GatewayTLSConfig{ + Enabled: in.Enabled, + } +} + +func (in IngressListener) toConsul() capi.IngressListener { + var services []capi.IngressService + for _, s := range in.Services { + services = append(services, s.toConsul()) + } + return capi.IngressListener{ + Port: in.Port, + Protocol: in.Protocol, + Services: services, + } +} + +func (in IngressService) toConsul() capi.IngressService { + return capi.IngressService{ + Name: in.Name, + Hosts: in.Hosts, + Namespace: in.Namespace, + } +} + +func (in IngressListener) validate(path *field.Path) field.ErrorList { + var errs field.ErrorList + validProtocols := []string{"tcp", "http", "http2", "grpc"} + if !sliceContains(validProtocols, in.Protocol) { + errs = append(errs, field.Invalid(path.Child("protocol"), + in.Protocol, + notInSliceMessage(validProtocols))) + } + + if in.Protocol == "tcp" && len(in.Services) > 1 { + asJSON, _ := json.Marshal(in.Services) + errs = append(errs, field.Invalid(path.Child("services"), + string(asJSON), + fmt.Sprintf("if protocol is \"tcp\", only a single service is allowed, found %d", len(in.Services)))) + } + + for i, svc := range in.Services { + if svc.Name == wildcardServiceName && in.Protocol != "http" { + errs = append(errs, field.Invalid(path.Child("services").Index(i).Child("name"), + svc.Name, + fmt.Sprintf("if name is %q, protocol must be \"http\" but was %q", wildcardServiceName, in.Protocol))) + } + + if svc.Name == wildcardServiceName && len(svc.Hosts) > 0 { + asJSON, _ := json.Marshal(svc.Hosts) + errs = append(errs, field.Invalid(path.Child("services").Index(i).Child("hosts"), + string(asJSON), + fmt.Sprintf("hosts must be empty if name is %q", wildcardServiceName))) + } + + if len(svc.Hosts) > 0 && in.Protocol == "tcp" { + asJSON, _ := json.Marshal(svc.Hosts) + errs = append(errs, field.Invalid(path.Child("services").Index(i).Child("hosts"), + string(asJSON), + "hosts must be empty if protocol is \"tcp\"")) + } + } + return errs +} + +func (in *IngressGateway) validateNamespaces(namespacesEnabled bool) field.ErrorList { + var errs field.ErrorList + path := field.NewPath("spec") + if !namespacesEnabled { + for i, listener := range in.Spec.Listeners { + for j, service := range listener.Services { + if service.Namespace != "" { + errs = append(errs, field.Invalid(path.Child("listeners").Index(i).Child("services").Index(j).Child("namespace"), + service.Namespace, `Consul Enterprise namespaces must be enabled to set service.namespace`)) + } + } + } + } + return errs +} diff --git a/api/v1alpha1/ingressgateway_types_test.go b/api/v1alpha1/ingressgateway_types_test.go new file mode 100644 index 0000000000..1a5c0247ee --- /dev/null +++ b/api/v1alpha1/ingressgateway_types_test.go @@ -0,0 +1,559 @@ +package v1alpha1 + +import ( + "testing" + + "github.com/hashicorp/consul-k8s/api/common" + 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" +) + +func TestIngressGateway_MatchesConsul(t *testing.T) { + cases := map[string]struct { + Ours IngressGateway + Theirs capi.ConfigEntry + Matches bool + }{ + "empty fields matches": { + Ours: IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: IngressGatewaySpec{}, + }, + Theirs: &capi.IngressGatewayConfigEntry{ + Kind: capi.IngressGateway, + Name: "name", + Namespace: "foobar", + Meta: map[string]string{ + common.SourceKey: common.SourceValue, + common.DatacenterKey: "datacenter", + }, + CreateIndex: 1, + ModifyIndex: 2, + }, + Matches: true, + }, + "all fields set matches": { + Ours: IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: IngressGatewaySpec{ + TLS: GatewayTLSConfig{ + Enabled: true, + }, + Listeners: []IngressListener{ + { + Port: 8888, + Protocol: "tcp", + Services: []IngressService{ + { + Name: "name1", + Hosts: []string{"host1_1", "host1_2"}, + Namespace: "ns1", + }, + { + Name: "name2", + Hosts: []string{"host2_1", "host2_2"}, + Namespace: "ns2", + }, + }, + }, + { + Port: 9999, + Protocol: "http", + Services: []IngressService{ + { + Name: "*", + }, + }, + }, + }, + }, + }, + Theirs: &capi.IngressGatewayConfigEntry{ + Kind: capi.IngressGateway, + Name: "name", + Namespace: "foobar", + TLS: capi.GatewayTLSConfig{ + Enabled: true, + }, + Listeners: []capi.IngressListener{ + { + Port: 8888, + Protocol: "tcp", + Services: []capi.IngressService{ + { + Name: "name1", + Hosts: []string{"host1_1", "host1_2"}, + Namespace: "ns1", + }, + { + Name: "name2", + Hosts: []string{"host2_1", "host2_2"}, + Namespace: "ns2", + }, + }, + }, + { + Port: 9999, + Protocol: "http", + Services: []capi.IngressService{ + { + Name: "*", + }, + }, + }, + }, + Meta: map[string]string{ + common.SourceKey: common.SourceValue, + common.DatacenterKey: "datacenter", + }, + CreateIndex: 1, + ModifyIndex: 2, + }, + Matches: true, + }, + "different types does not match": { + Ours: IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: IngressGatewaySpec{}, + }, + Theirs: &capi.ProxyConfigEntry{ + Name: "name", + Kind: capi.IngressGateway, + Namespace: "foobar", + CreateIndex: 1, + ModifyIndex: 2, + }, + Matches: false, + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + require.Equal(t, c.Matches, c.Ours.MatchesConsul(c.Theirs)) + }) + } +} + +func TestIngressGateway_ToConsul(t *testing.T) { + cases := map[string]struct { + Ours IngressGateway + Exp *capi.IngressGatewayConfigEntry + }{ + "empty fields": { + Ours: IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: IngressGatewaySpec{}, + }, + Exp: &capi.IngressGatewayConfigEntry{ + Kind: capi.IngressGateway, + Name: "name", + Meta: map[string]string{ + common.SourceKey: common.SourceValue, + common.DatacenterKey: "datacenter", + }, + }, + }, + "every field set": { + Ours: IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: IngressGatewaySpec{ + TLS: GatewayTLSConfig{ + Enabled: true, + }, + Listeners: []IngressListener{ + { + Port: 8888, + Protocol: "tcp", + Services: []IngressService{ + { + Name: "name1", + Hosts: []string{"host1_1", "host1_2"}, + Namespace: "ns1", + }, + { + Name: "name2", + Hosts: []string{"host2_1", "host2_2"}, + Namespace: "ns2", + }, + }, + }, + { + Port: 9999, + Protocol: "http", + Services: []IngressService{ + { + Name: "*", + }, + }, + }, + }, + }, + }, + Exp: &capi.IngressGatewayConfigEntry{ + Kind: capi.IngressGateway, + Name: "name", + TLS: capi.GatewayTLSConfig{ + Enabled: true, + }, + Listeners: []capi.IngressListener{ + { + Port: 8888, + Protocol: "tcp", + Services: []capi.IngressService{ + { + Name: "name1", + Hosts: []string{"host1_1", "host1_2"}, + Namespace: "ns1", + }, + { + Name: "name2", + Hosts: []string{"host2_1", "host2_2"}, + Namespace: "ns2", + }, + }, + }, + { + Port: 9999, + Protocol: "http", + Services: []capi.IngressService{ + { + Name: "*", + }, + }, + }, + }, + Meta: map[string]string{ + common.SourceKey: common.SourceValue, + common.DatacenterKey: "datacenter", + }, + }, + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + act := c.Ours.ToConsul("datacenter") + resource, ok := act.(*capi.IngressGatewayConfigEntry) + require.True(t, ok, "could not cast") + require.Equal(t, c.Exp, resource) + }) + } +} + +func TestIngressGateway_Validate(t *testing.T) { + cases := map[string]struct { + input *IngressGateway + namespacesEnabled bool + expectedErrMsgs []string + }{ + "listener.protocol invalid": { + input: &IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: IngressGatewaySpec{ + Listeners: []IngressListener{ + { + Protocol: "invalid", + }, + }, + }, + }, + namespacesEnabled: false, + expectedErrMsgs: []string{ + `spec.listeners[0].protocol: Invalid value: "invalid": must be one of "tcp", "http", "http2", "grpc"`, + }, + }, + "len(services) > 0 when protocol==tcp": { + input: &IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: IngressGatewaySpec{ + Listeners: []IngressListener{ + { + Protocol: "tcp", + Services: []IngressService{ + { + Name: "svc1", + }, + { + Name: "svc2", + }, + }, + }, + }, + }, + }, + namespacesEnabled: false, + expectedErrMsgs: []string{ + `spec.listeners[0].services: Invalid value: "[{\"name\":\"svc1\"},{\"name\":\"svc2\"}]": if protocol is "tcp", only a single service is allowed, found 2`, + }, + }, + "protocol != http when service.name==*": { + input: &IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: IngressGatewaySpec{ + Listeners: []IngressListener{ + { + Protocol: "tcp", + Services: []IngressService{ + { + Name: "*", + }, + }, + }, + }, + }, + }, + namespacesEnabled: false, + expectedErrMsgs: []string{ + `spec.listeners[0].services[0].name: Invalid value: "*": if name is "*", protocol must be "http" but was "tcp"`, + }, + }, + "len(hosts) > 0 when service.name==*": { + input: &IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: IngressGatewaySpec{ + Listeners: []IngressListener{ + { + Protocol: "http", + Services: []IngressService{ + { + Name: "*", + Hosts: []string{"host1", "host2"}, + }, + }, + }, + }, + }, + }, + namespacesEnabled: false, + expectedErrMsgs: []string{ + `spec.listeners[0].services[0].hosts: Invalid value: "[\"host1\",\"host2\"]": hosts must be empty if name is "*"`, + }, + }, + "len(hosts) > 0 when protocol==tcp": { + input: &IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: IngressGatewaySpec{ + Listeners: []IngressListener{ + { + Protocol: "tcp", + Services: []IngressService{ + { + Name: "name", + Hosts: []string{"host1", "host2"}, + }, + }, + }, + }, + }, + }, + namespacesEnabled: false, + expectedErrMsgs: []string{ + `spec.listeners[0].services[0].hosts: Invalid value: "[\"host1\",\"host2\"]": hosts must be empty if protocol is "tcp"`, + }, + }, + "service.namespace set when namespaces disabled": { + input: &IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: IngressGatewaySpec{ + Listeners: []IngressListener{ + { + Protocol: "tcp", + Services: []IngressService{ + { + Name: "name", + Namespace: "foo", + }, + }, + }, + }, + }, + }, + namespacesEnabled: false, + expectedErrMsgs: []string{ + `spec.listeners[0].services[0].namespace: Invalid value: "foo": Consul Enterprise namespaces must be enabled to set service.namespace`, + }, + }, + "service.namespace set when namespaces enabled": { + input: &IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: IngressGatewaySpec{ + Listeners: []IngressListener{ + { + Protocol: "tcp", + Services: []IngressService{ + { + Name: "name", + Namespace: "foo", + }, + }, + }, + }, + }, + }, + namespacesEnabled: true, + }, + "multiple errors": { + input: &IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: IngressGatewaySpec{ + Listeners: []IngressListener{ + { + Protocol: "invalid", + Services: []IngressService{ + { + Name: "*", + }, + }, + }, + }, + }, + }, + namespacesEnabled: false, + expectedErrMsgs: []string{ + `spec.listeners[0].protocol: Invalid value: "invalid": must be one of "tcp", "http", "http2", "grpc"`, + `spec.listeners[0].services[0].name: Invalid value: "*": if name is "*", protocol must be "http" but was "invalid"`, + }, + }, + } + + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + err := testCase.input.Validate(testCase.namespacesEnabled) + if len(testCase.expectedErrMsgs) != 0 { + require.Error(t, err) + for _, s := range testCase.expectedErrMsgs { + require.Contains(t, err.Error(), s) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestIngressGateway_AddFinalizer(t *testing.T) { + resource := &IngressGateway{} + resource.AddFinalizer("finalizer") + require.Equal(t, []string{"finalizer"}, resource.ObjectMeta.Finalizers) +} + +func TestIngressGateway_RemoveFinalizer(t *testing.T) { + resource := &IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Finalizers: []string{"f1", "f2"}, + }, + } + resource.RemoveFinalizer("f1") + require.Equal(t, []string{"f2"}, resource.ObjectMeta.Finalizers) +} + +func TestIngressGateway_SetSyncedCondition(t *testing.T) { + resource := &IngressGateway{} + resource.SetSyncedCondition(corev1.ConditionTrue, "reason", "message") + + require.Equal(t, corev1.ConditionTrue, resource.Status.Conditions[0].Status) + require.Equal(t, "reason", resource.Status.Conditions[0].Reason) + require.Equal(t, "message", resource.Status.Conditions[0].Message) + now := metav1.Now() + require.True(t, resource.Status.Conditions[0].LastTransitionTime.Before(&now)) +} + +func TestIngressGateway_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) { + resource := &IngressGateway{ + Status: Status{ + Conditions: []Condition{{ + Type: ConditionSynced, + Status: status, + }}, + }, + } + + require.Equal(t, status, resource.SyncedConditionStatus()) + }) + } +} + +func TestIngressGateway_GetConditionWhenStatusNil(t *testing.T) { + require.Nil(t, (&IngressGateway{}).GetCondition(ConditionSynced)) +} + +func TestIngressGateway_SyncedConditionStatusWhenStatusNil(t *testing.T) { + require.Equal(t, corev1.ConditionUnknown, (&IngressGateway{}).SyncedConditionStatus()) +} + +func TestIngressGateway_SyncedConditionWhenStatusNil(t *testing.T) { + status, reason, message := (&IngressGateway{}).SyncedCondition() + require.Equal(t, corev1.ConditionUnknown, status) + require.Equal(t, "", reason) + require.Equal(t, "", message) +} + +func TestIngressGateway_ConsulKind(t *testing.T) { + require.Equal(t, capi.IngressGateway, (&IngressGateway{}).ConsulKind()) +} + +func TestIngressGateway_KubeKind(t *testing.T) { + require.Equal(t, "ingressgateway", (&IngressGateway{}).KubeKind()) +} + +func TestIngressGateway_ConsulName(t *testing.T) { + require.Equal(t, "foo", (&IngressGateway{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}).ConsulName()) +} + +func TestIngressGateway_KubernetesName(t *testing.T) { + require.Equal(t, "foo", (&IngressGateway{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}).KubernetesName()) +} + +func TestIngressGateway_ConsulNamespace(t *testing.T) { + require.Equal(t, "bar", (&IngressGateway{ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "bar"}}).ConsulMirroringNS()) +} + +func TestIngressGateway_ConsulGlobalResource(t *testing.T) { + require.False(t, (&IngressGateway{}).ConsulGlobalResource()) +} + +func TestIngressGateway_ObjectMeta(t *testing.T) { + meta := metav1.ObjectMeta{ + Name: "name", + Namespace: "namespace", + } + resource := &IngressGateway{ + ObjectMeta: meta, + } + require.Equal(t, meta, resource.GetObjectMeta()) +} diff --git a/api/v1alpha1/ingressgateway_webhook.go b/api/v1alpha1/ingressgateway_webhook.go new file mode 100644 index 0000000000..447de8a294 --- /dev/null +++ b/api/v1alpha1/ingressgateway_webhook.go @@ -0,0 +1,72 @@ +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" +) + +// +kubebuilder:object:generate=false + +type IngressGatewayWebhook struct { + ConsulClient *capi.Client + Logger logr.Logger + + // EnableConsulNamespaces indicates that a user is running Consul Enterprise + // with version 1.7+ which supports namespaces. + EnableConsulNamespaces bool + + // 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 + + decoder *admission.Decoder + client.Client +} + +// 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-ingressgateway,mutating=true,failurePolicy=fail,groups=consul.hashicorp.com,resources=ingressgateways,versions=v1alpha1,name=mutate-ingressgateway.consul.hashicorp.com,webhookVersions=v1beta1,sideEffects=None + +func (v *IngressGatewayWebhook) Handle(ctx context.Context, req admission.Request) admission.Response { + var resource IngressGateway + err := v.decoder.Decode(req, &resource) + if err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + return common.ValidateConfigEntry(ctx, + req, + v.Logger, + v, + &resource, + v.EnableConsulNamespaces, + v.EnableNSMirroring) +} + +func (v *IngressGatewayWebhook) List(ctx context.Context) ([]common.ConfigEntryResource, error) { + var resourceList IngressGatewayList + if err := v.Client.List(ctx, &resourceList); err != nil { + return nil, err + } + var entries []common.ConfigEntryResource + for _, item := range resourceList.Items { + entries = append(entries, common.ConfigEntryResource(&item)) + } + return entries, nil +} + +func (v *IngressGatewayWebhook) InjectDecoder(d *admission.Decoder) error { + v.decoder = d + return nil +} diff --git a/api/v1alpha1/servicerouter_types.go b/api/v1alpha1/servicerouter_types.go index 89e0361b92..0571e3aa47 100644 --- a/api/v1alpha1/servicerouter_types.go +++ b/api/v1alpha1/servicerouter_types.go @@ -36,6 +36,15 @@ type ServiceRouter struct { Status `json:"status,omitempty"` } +// +kubebuilder:object:root=true + +// ServiceRouterList contains a list of ServiceRouter +type ServiceRouterList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ServiceRouter `json:"items"` +} + // ServiceRouterSpec defines the desired state of ServiceRouter type ServiceRouterSpec struct { // Routes are the list of routes to consider when processing L7 requests. @@ -133,15 +142,6 @@ type ServiceRouteDestination struct { RetryOnStatusCodes []uint32 `json:"retryOnStatusCodes,omitempty"` } -// +kubebuilder:object:root=true - -// ServiceRouterList contains a list of ServiceRouter -type ServiceRouterList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - Items []ServiceRouter `json:"items"` -} - func (in *ServiceRouter) ConsulMirroringNS() string { return in.Namespace } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 89a302f6c4..2f3e069339 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -111,6 +111,21 @@ func (in *ExposePath) DeepCopy() *ExposePath { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GatewayTLSConfig) DeepCopyInto(out *GatewayTLSConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GatewayTLSConfig. +func (in *GatewayTLSConfig) DeepCopy() *GatewayTLSConfig { + if in == nil { + return nil + } + out := new(GatewayTLSConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HashPolicy) DeepCopyInto(out *HashPolicy) { *out = *in @@ -131,6 +146,130 @@ 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 *IngressGateway) DeepCopyInto(out *IngressGateway) { + *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 IngressGateway. +func (in *IngressGateway) DeepCopy() *IngressGateway { + if in == nil { + return nil + } + out := new(IngressGateway) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IngressGateway) 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 *IngressGatewayList) DeepCopyInto(out *IngressGatewayList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]IngressGateway, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressGatewayList. +func (in *IngressGatewayList) DeepCopy() *IngressGatewayList { + if in == nil { + return nil + } + out := new(IngressGatewayList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IngressGatewayList) 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 *IngressGatewaySpec) DeepCopyInto(out *IngressGatewaySpec) { + *out = *in + out.TLS = in.TLS + if in.Listeners != nil { + in, out := &in.Listeners, &out.Listeners + *out = make([]IngressListener, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressGatewaySpec. +func (in *IngressGatewaySpec) DeepCopy() *IngressGatewaySpec { + if in == nil { + return nil + } + out := new(IngressGatewaySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IngressListener) DeepCopyInto(out *IngressListener) { + *out = *in + if in.Services != nil { + in, out := &in.Services, &out.Services + *out = make([]IngressService, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressListener. +func (in *IngressListener) DeepCopy() *IngressListener { + if in == nil { + return nil + } + out := new(IngressListener) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IngressService) DeepCopyInto(out *IngressService) { + *out = *in + if in.Hosts != nil { + in, out := &in.Hosts, &out.Hosts + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressService. +func (in *IngressService) DeepCopy() *IngressService { + if in == nil { + return nil + } + out := new(IngressService) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IntentionHTTPHeaderPermission) DeepCopyInto(out *IntentionHTTPHeaderPermission) { *out = *in diff --git a/config/crd/bases/consul.hashicorp.com_ingressgateways.yaml b/config/crd/bases/consul.hashicorp.com_ingressgateways.yaml new file mode 100644 index 0000000000..99a73a1882 --- /dev/null +++ b/config/crd/bases/consul.hashicorp.com_ingressgateways.yaml @@ -0,0 +1,125 @@ + +--- +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.4.1 + creationTimestamp: null + name: ingressgateways.consul.hashicorp.com +spec: + additionalPrinterColumns: + - JSONPath: .status.conditions[?(@.type=="Synced")].status + description: The sync status of the resource with Consul + name: Synced + type: string + - JSONPath: .metadata.creationTimestamp + description: The age of the resource + name: Age + type: date + group: consul.hashicorp.com + names: + kind: IngressGateway + listKind: IngressGatewayList + plural: ingressgateways + singular: ingressgateway + scope: Namespaced + subresources: + status: {} + validation: + openAPIV3Schema: + description: IngressGateway is the Schema for the ingressgateways 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: IngressGatewaySpec defines the desired state of IngressGateway + properties: + listeners: + description: Listeners declares what ports the ingress gateway should listen on, and what services to associated to those ports. + items: + description: IngressListener manages the configuration for a listener on a specific port. + properties: + port: + description: Port declares the port on which the ingress gateway should listen for traffic. + type: integer + protocol: + description: 'Protocol declares what type of traffic this listener is expected to receive. Depending on the protocol, a listener might support multiplexing services over a single port, or additional discovery chain features. The current supported values are: (tcp | http | http2 | grpc).' + type: string + services: + description: "Services declares the set of services to which the listener forwards traffic. \n For \"tcp\" protocol listeners, only a single service is allowed. For \"http\" listeners, multiple services can be declared." + items: + description: IngressService manages configuration for services that are exposed to ingress traffic. + properties: + hosts: + description: "Hosts is a list of hostnames which should be associated to this service on the defined listener. Only allowed on layer 7 protocols, this will be used to route traffic to the service by matching the Host header of the HTTP request. \n If a host is provided for a service that also has a wildcard specifier defined, the host will override the wildcard-specifier-provided \".*\" domain for that listener. \n This cannot be specified when using the wildcard specifier, \"*\", or when using a \"tcp\" listener." + items: + type: string + type: array + name: + description: "Name declares the service to which traffic should be forwarded. \n This can either be a specific service, or the wildcard specifier, \"*\". If the wildcard specifier is provided, the listener must be of \"http\" protocol and means that the listener will forward traffic to all services. \n A name can be specified on multiple listeners, and will be exposed on both of the listeners." + type: string + namespace: + description: Namespace is the namespace where the service is located. Namespacing is a Consul Enterprise feature. + type: string + type: object + type: array + type: object + type: array + tls: + description: TLS holds the TLS configuration for this gateway. + properties: + enabled: + description: Indicates that TLS should be enabled for this gateway service. + type: boolean + required: + - enabled + 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 490769743e..03f33b6c5c 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -7,6 +7,7 @@ resources: - bases/consul.hashicorp.com_proxydefaults.yaml - bases/consul.hashicorp.com_servicerouters.yaml - bases/consul.hashicorp.com_serviceintentions.yaml +- bases/consul.hashicorp.com_ingressgateways.yaml # +kubebuilder:scaffold:crdkustomizeresource patchesStrategicMerge: @@ -17,6 +18,7 @@ patchesStrategicMerge: - patches/webhook_in_proxydefaults.yaml - patches/webhook_in_servicerouters.yaml - patches/webhook_in_serviceintentions.yaml +- patches/webhook_in_ingressgateways.yaml # +kubebuilder:scaffold:crdkustomizewebhookpatch # [CERTMANAGER] To enable webhook, uncomment all the sections with [CERTMANAGER] prefix. @@ -26,6 +28,7 @@ patchesStrategicMerge: #- patches/cainjection_in_proxydefaults.yaml #- patches/cainjection_in_servicerouters.yaml #- patches/cainjection_in_serviceintentions.yaml +#- patches/cainjection_in_ingressgateways.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_ingressgateways.yaml b/config/crd/patches/cainjection_in_ingressgateways.yaml new file mode 100644 index 0000000000..aa1074fa2b --- /dev/null +++ b/config/crd/patches/cainjection_in_ingressgateways.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: ingressgateways.consul.hashicorp.com diff --git a/config/crd/patches/webhook_in_ingressgateways.yaml b/config/crd/patches/webhook_in_ingressgateways.yaml new file mode 100644 index 0000000000..fcc6f69fa5 --- /dev/null +++ b/config/crd/patches/webhook_in_ingressgateways.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: ingressgateways.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/ingressgateway_editor_role.yaml b/config/rbac/ingressgateway_editor_role.yaml new file mode 100644 index 0000000000..424e12e33c --- /dev/null +++ b/config/rbac/ingressgateway_editor_role.yaml @@ -0,0 +1,24 @@ +# permissions for end users to edit ingressgateways. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: ingressgateway-editor-role +rules: +- apiGroups: + - consul.hashicorp.com + resources: + - ingressgateways + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - ingressgateways/status + verbs: + - get diff --git a/config/rbac/ingressgateway_viewer_role.yaml b/config/rbac/ingressgateway_viewer_role.yaml new file mode 100644 index 0000000000..82ca5e79db --- /dev/null +++ b/config/rbac/ingressgateway_viewer_role.yaml @@ -0,0 +1,20 @@ +# permissions for end users to view ingressgateways. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: ingressgateway-viewer-role +rules: +- apiGroups: + - consul.hashicorp.com + resources: + - ingressgateways + verbs: + - get + - list + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - ingressgateways/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 7578995177..764887b85c 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -6,6 +6,26 @@ metadata: creationTimestamp: null name: manager-role rules: +- apiGroups: + - consul.hashicorp.com + resources: + - ingressgateways + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - ingressgateways/status + verbs: + - get + - patch + - update - apiGroups: - consul.hashicorp.com resources: diff --git a/config/samples/consul_v1alpha1_ingressgateway.yaml b/config/samples/consul_v1alpha1_ingressgateway.yaml new file mode 100644 index 0000000000..9a87883ed0 --- /dev/null +++ b/config/samples/consul_v1alpha1_ingressgateway.yaml @@ -0,0 +1,12 @@ +apiVersion: consul.hashicorp.com/v1alpha1 +kind: IngressGateway +metadata: + name: ingressgateway-sample +spec: + tls: + enabled: false + listeners: + - port: 8080 + protocol: "tcp" + services: + - name: "foo" diff --git a/config/webhook/manifests.v1beta1.yaml b/config/webhook/manifests.v1beta1.yaml index 698d6ed326..633ca3d88c 100644 --- a/config/webhook/manifests.v1beta1.yaml +++ b/config/webhook/manifests.v1beta1.yaml @@ -6,6 +6,24 @@ metadata: creationTimestamp: null name: mutating-webhook-configuration webhooks: +- clientConfig: + service: + name: webhook-service + namespace: system + path: /mutate-v1alpha1-ingressgateway + failurePolicy: Fail + name: mutate-ingressgateway.consul.hashicorp.com + rules: + - apiGroups: + - consul.hashicorp.com + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - ingressgateways + sideEffects: None - clientConfig: service: name: webhook-service diff --git a/controller/configentry_controller_test.go b/controller/configentry_controller_test.go index b4e7162d8b..11e48b0894 100644 --- a/controller/configentry_controller_test.go +++ b/controller/configentry_controller_test.go @@ -297,6 +297,50 @@ func TestConfigEntryControllers_createsConfigEntry(t *testing.T) { require.Equal(t, "/path", svcIntentions.Sources[2].Permissions[0].HTTP.PathExact) }, }, + { + kubeKind: "IngressGateway", + consulKind: capi.IngressGateway, + configEntryResource: &v1alpha1.IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.IngressGatewaySpec{ + TLS: v1alpha1.GatewayTLSConfig{ + Enabled: true, + }, + Listeners: []v1alpha1.IngressListener{ + { + Port: 80, + Protocol: "http", + Services: []v1alpha1.IngressService{ + { + Name: "*", + }, + }, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &IngressGatewayController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + compare: func(t *testing.T, consulEntry capi.ConfigEntry) { + resource, ok := consulEntry.(*capi.IngressGatewayConfigEntry) + require.True(t, ok, "cast error") + require.Equal(t, true, resource.TLS.Enabled) + require.Equal(t, 80, resource.Listeners[0].Port) + require.Equal(t, "http", resource.Listeners[0].Protocol) + require.Equal(t, "*", resource.Listeners[0].Services[0].Name) + }, + }, } for _, c := range cases { @@ -643,6 +687,54 @@ func TestConfigEntryControllers_updatesConfigEntry(t *testing.T) { require.Equal(t, capi.IntentionActionDeny, configEntry.Sources[1].Permissions[0].Action) }, }, + { + kubeKind: "IngressGateway", + consulKind: capi.IngressGateway, + configEntryResource: &v1alpha1.IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.IngressGatewaySpec{ + TLS: v1alpha1.GatewayTLSConfig{ + Enabled: true, + }, + Listeners: []v1alpha1.IngressListener{ + { + Port: 80, + Protocol: "http", + Services: []v1alpha1.IngressService{ + { + Name: "*", + }, + }, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &IngressGatewayController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + updateF: func(resource common.ConfigEntryResource) { + igw := resource.(*v1alpha1.IngressGateway) + igw.Spec.TLS.Enabled = false + }, + compare: func(t *testing.T, consulEntry capi.ConfigEntry) { + resource, ok := consulEntry.(*capi.IngressGatewayConfigEntry) + require.True(t, ok, "cast error") + require.Equal(t, false, resource.TLS.Enabled) + require.Equal(t, 80, resource.Listeners[0].Port) + require.Equal(t, "http", resource.Listeners[0].Protocol) + require.Equal(t, "*", resource.Listeners[0].Services[0].Name) + }, + }, } for _, c := range cases { @@ -943,6 +1035,44 @@ func TestConfigEntryControllers_deletesConfigEntry(t *testing.T) { } }, }, + { + kubeKind: "IngressGateway", + consulKind: capi.IngressGateway, + configEntryResourceWithDeletion: &v1alpha1.IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{FinalizerName}, + }, + Spec: v1alpha1.IngressGatewaySpec{ + TLS: v1alpha1.GatewayTLSConfig{ + Enabled: true, + }, + Listeners: []v1alpha1.IngressListener{ + { + Port: 80, + Protocol: "http", + Services: []v1alpha1.IngressService{ + { + Name: "*", + }, + }, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &IngressGatewayController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, } for _, c := range cases { @@ -1139,6 +1269,31 @@ func TestConfigEntryControllers_errorUpdatesSyncStatus(t *testing.T) { } }, }, + { + kubeKind: "IngressGateway", + consulKind: capi.IngressGateway, + configEntryResource: &v1alpha1.IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.IngressGatewaySpec{ + TLS: v1alpha1.GatewayTLSConfig{ + Enabled: true, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &IngressGatewayController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, } for _, c := range cases { @@ -1402,6 +1557,50 @@ func TestConfigEntryControllers_setsSyncedToTrue(t *testing.T) { } }, }, + { + kubeKind: "IngressGateway", + consulKind: capi.IngressGateway, + configEntryResource: &v1alpha1.IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.IngressGatewaySpec{ + TLS: v1alpha1.GatewayTLSConfig{ + Enabled: true, + }, + Listeners: []v1alpha1.IngressListener{ + { + Port: 80, + Protocol: "http", + Services: []v1alpha1.IngressService{ + { + Name: "*", + }, + }, + }, + }, + }, + Status: v1alpha1.Status{ + Conditions: v1alpha1.Conditions{ + { + Type: v1alpha1.ConditionSynced, + Status: corev1.ConditionUnknown, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &IngressGatewayController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, } for _, c := range cases { @@ -1683,6 +1882,31 @@ func TestConfigEntryControllers_doesNotCreateUnownedConfigEntry(t *testing.T) { } }, }, + { + kubeKind: "IngressGateway", + consulKind: capi.IngressGateway, + configEntryResource: &v1alpha1.IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.IngressGatewaySpec{ + TLS: v1alpha1.GatewayTLSConfig{ + Enabled: true, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &IngressGatewayController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, } for _, c := range cases { @@ -2014,6 +2238,49 @@ func TestConfigEntryControllers_doesNotDeleteUnownedConfig(t *testing.T) { require.Empty(t, svcIntentions.Finalizers()) }, }, + { + kubeKind: "IngressGateway", + consulKind: capi.IngressGateway, + configEntryResourceWithDeletion: &v1alpha1.IngressGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{FinalizerName}, + }, + Spec: v1alpha1.IngressGatewaySpec{ + TLS: v1alpha1.GatewayTLSConfig{ + Enabled: true, + }, + Listeners: []v1alpha1.IngressListener{ + { + Port: 80, + Protocol: "http", + Services: []v1alpha1.IngressService{ + { + Name: "*", + }, + }, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &IngressGatewayController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + confirmDelete: func(t *testing.T, cli client.Client, ctx context.Context, name types.NamespacedName) { + resource := &v1alpha1.IngressGateway{} + _ = cli.Get(ctx, name, resource) + require.Empty(t, resource.Finalizers()) + }, + }, } for _, c := range cases { diff --git a/controller/ingressgateway_controller.go b/controller/ingressgateway_controller.go new file mode 100644 index 0000000000..a8b28967bd --- /dev/null +++ b/controller/ingressgateway_controller.go @@ -0,0 +1,42 @@ +package controller + +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" +) + +// IngressGatewayController is the controller for IngressGateway resources. +type IngressGatewayController struct { + client.Client + Log logr.Logger + Scheme *runtime.Scheme + ConfigEntryController *ConfigEntryController +} + +// +kubebuilder:rbac:groups=consul.hashicorp.com,resources=ingressgateways,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=consul.hashicorp.com,resources=ingressgateways/status,verbs=get;update;patch + +func (r *IngressGatewayController) Reconcile(req ctrl.Request) (ctrl.Result, error) { + return r.ConfigEntryController.ReconcileEntry(r, req, &consulv1alpha1.IngressGateway{}) +} + +func (r *IngressGatewayController) Logger(name types.NamespacedName) logr.Logger { + return r.Log.WithValues("request", name) +} + +func (r *IngressGatewayController) UpdateStatus(ctx context.Context, obj runtime.Object, opts ...client.UpdateOption) error { + return r.Status().Update(ctx, obj, opts...) +} + +func (r *IngressGatewayController) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&consulv1alpha1.IngressGateway{}). + Complete(r) +} diff --git a/subcommand/controller/command.go b/subcommand/controller/command.go index 94e89eb37b..90a7816505 100644 --- a/subcommand/controller/command.go +++ b/subcommand/controller/command.go @@ -198,6 +198,15 @@ func (c *Command) Run(args []string) int { setupLog.Error(err, "unable to create controller", "controller", common.ServiceIntentions) return 1 } + if err = (&controller.IngressGatewayController{ + ConfigEntryController: configEntryReconciler, + Client: mgr.GetClient(), + Log: ctrl.Log.WithName("controller").WithName(common.IngressGateway), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", common.IngressGateway) + return 1 + } if c.flagEnableWebhooks { // This webhook server sets up a Cert Watcher on the CertDir. This watches for file changes and updates the webhook certificates @@ -256,6 +265,14 @@ func (c *Command) Run(args []string) int { ConsulDestinationNamespace: c.flagConsulDestinationNamespace, NSMirroringPrefix: c.flagNSMirroringPrefix, }}) + mgr.GetWebhookServer().Register("/mutate-v1alpha1-ingressgateway", + &webhook.Admission{Handler: &v1alpha1.IngressGatewayWebhook{ + Client: mgr.GetClient(), + ConsulClient: consulClient, + Logger: ctrl.Log.WithName("webhooks").WithName(common.IngressGateway), + EnableConsulNamespaces: c.flagEnableNamespaces, + EnableNSMirroring: c.flagEnableNSMirroring, + }}) } // +kubebuilder:scaffold:builder