diff --git a/.circleci/config.yml b/.circleci/config.yml index 247eec928b..80a1f53852 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,4 +1,5 @@ version: 2.1 + # reusable 'executor' object for jobs executors: go: @@ -6,8 +7,48 @@ executors: - image: circleci/golang:1.14 environment: - TEST_RESULTS: /tmp/test-results # path to where test results are saved - - CONSUL_VERSION: 1.8.2 # Consul's OSS version to use in tests - - CONSUL_ENT_VERSION: 1.8.2+ent # Consul's enterprise version to use in tests + - CONSUL_VERSION: 71ba8300a3d25b335aff0de9f9dd2a82fc2e58f1 # Consul's OSS version to use in tests + - CONSUL_ENT_VERSION: 9fb10787b1ec44d6a7c42c3a5f326953943fc39e # Consul's enterprise version to use in tests + +commands: + get-aws-cli: + steps: + - run: + name: download and install AWS CLI + command: | + curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" + echo -e "${AWS_CLI_GPG_KEY}" | gpg --import + curl -o awscliv2.sig https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip.sig + gpg --verify awscliv2.sig awscliv2.zip + unzip awscliv2.zip + sudo ./aws/install + aws-assume-role: + steps: + - run: + name: assume-role aws creds + command: | + # assume role has duration of 15 min (the minimum allowed) + CREDENTIALS="$(aws sts assume-role --duration-seconds 900 --role-arn ${ROLE_ARN} --role-session-name build-${CIRCLE_SHA1} | jq '.Credentials')" + echo "export AWS_ACCESS_KEY_ID=$(echo $CREDENTIALS | jq -r '.AccessKeyId')" >> $BASH_ENV + echo "export AWS_SECRET_ACCESS_KEY=$(echo $CREDENTIALS | jq -r '.SecretAccessKey')" >> $BASH_ENV + echo "export AWS_SESSION_TOKEN=$(echo $CREDENTIALS | jq -r '.SessionToken')" >> $BASH_ENV + install-dev-consul: + description: "Pull a dev version of consul/consul-enterprise for testing" + parameters: + bucket: + type: env_var_name + default: CONSUL_DEV_ARTIFACT_BUCKET # we use the same bucket for oss/ent dev builds (at different paths) + path: + type: env_var_name + sha: + type: env_var_name + steps: + - run: + name: download and install dev consul/consul-enterprise + command: | + aws s3 cp s3://${<< parameters.bucket >>}/${<< parameters.path >>}/${<< parameters.sha >>}.tar.gz /tmp + sudo tar -xzvf /tmp/${<< parameters.sha >>}.tar.gz -C /usr/local/bin + consul version jobs: go-fmt-and-vet: @@ -61,12 +102,15 @@ jobs: keys: - consul-k8s-modcache-v1-{{ checksum "go.mod" }} + # pull a dev build of consul oss from s3 + - get-aws-cli + - aws-assume-role + - install-dev-consul: + path: CONSUL_DEV_ARTIFACT_PATH + sha: CONSUL_VERSION + # run go tests with gotestsum - run: | - # download and install the consul binary - wget https://releases.hashicorp.com/consul/"${CONSUL_VERSION}"/consul_"${CONSUL_VERSION}"_linux_amd64.zip && \ - unzip consul_"${CONSUL_VERSION}"_linux_amd64.zip -d /home/circleci/bin && - rm consul_"${CONSUL_VERSION}"_linux_amd64.zip PACKAGE_NAMES=$(go list ./...) gotestsum --junitfile $TEST_RESULTS/gotestsum-report.xml -- -p 4 $PACKAGE_NAMES @@ -89,12 +133,14 @@ jobs: keys: - consul-k8s-modcache-v1-{{ checksum "go.mod" }} + # pull a dev build of consul ent from s3 + - get-aws-cli + - aws-assume-role + - install-dev-consul: + path: CONSUL_ENT_DEV_ARTIFACT_PATH + sha: CONSUL_ENT_VERSION + # run go tests with gotestsum - - run: | - # download and install the consul binary - wget https://releases.hashicorp.com/consul/"${CONSUL_ENT_VERSION}"/consul_"${CONSUL_ENT_VERSION}"_linux_amd64.zip && \ - unzip consul_"${CONSUL_ENT_VERSION}"_linux_amd64.zip -d /home/circleci/bin && - rm consul_"${CONSUL_ENT_VERSION}"_linux_amd64.zip - run: | PACKAGE_NAMES=$(go list ./...) gotestsum --junitfile $TEST_RESULTS/gotestsum-report.xml -- -tags=enterprise -p 4 $PACKAGE_NAMES diff --git a/Makefile b/Makefile index af6a2a3d83..d96c365fc8 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ export GIT_DESCRIBE export GOLDFLAGS export GOTAGS - +CRD_OPTIONS ?= "crd:trivialVersions=true,allowDangerousTypes=true,crdVersions=v1beta1" ################ # CI Variables # @@ -79,7 +79,7 @@ else DEV_PUSH_ARG=--no-push endif -all: bin +all: bin ctrl-generate bin: @$(SHELL) $(CURDIR)/build-support/scripts/build-local.sh @@ -128,6 +128,57 @@ clean: $(CURDIR)/bin \ $(CURDIR)/pkg +# Run controller tests +ENVTEST_ASSETS_DIR=$(shell pwd)/testbin +ctrl-test: ctrl-generate ctrl-manifests + mkdir -p ${ENVTEST_ASSETS_DIR} + test -f ${ENVTEST_ASSETS_DIR}/setup-envtest.sh || curl -sSLo ${ENVTEST_ASSETS_DIR}/setup-envtest.sh https://raw.githubusercontent.com/kubernetes-sigs/controller-runtime/master/hack/setup-envtest.sh + source ${ENVTEST_ASSETS_DIR}/setup-envtest.sh; fetch_envtest_tools $(ENVTEST_ASSETS_DIR); setup_envtest_env $(ENVTEST_ASSETS_DIR); go test ./... + +# Deploy controller in the configured Kubernetes cluster in ~/.kube/config +ctrl-deploy: ctrl-manifests kustomize + cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} + $(KUSTOMIZE) build config/default | kubectl apply -f - + +# Generate manifests e.g. CRD, RBAC etc. +ctrl-manifests: controller-gen + $(CONTROLLER_GEN) $(CRD_OPTIONS) rbac:roleName=manager-role webhook paths="./..." output:crd:artifacts:config=config/crd/bases + +# Generate code +ctrl-generate: controller-gen + $(CONTROLLER_GEN) object:headerFile="build-support/controller/boilerplate.go.txt" paths="./..." + +# find or download controller-gen +# download controller-gen if necessary +controller-gen: +ifeq (, $(shell which controller-gen)) + @{ \ + set -e ;\ + CONTROLLER_GEN_TMP_DIR=$$(mktemp -d) ;\ + cd $$CONTROLLER_GEN_TMP_DIR ;\ + go mod init tmp ;\ + go get sigs.k8s.io/controller-tools/cmd/controller-gen@v0.4.0 ;\ + rm -rf $$CONTROLLER_GEN_TMP_DIR ;\ + } +CONTROLLER_GEN=$(GOBIN)/controller-gen +else +CONTROLLER_GEN=$(shell which controller-gen) +endif + +kustomize: +ifeq (, $(shell which kustomize)) + @{ \ + set -e ;\ + KUSTOMIZE_GEN_TMP_DIR=$$(mktemp -d) ;\ + cd $$KUSTOMIZE_GEN_TMP_DIR ;\ + go mod init tmp ;\ + go get sigs.k8s.io/kustomize/kustomize/v3@v3.5.4 ;\ + rm -rf $$KUSTOMIZE_GEN_TMP_DIR ;\ + } +KUSTOMIZE=$(GOBIN)/kustomize +else +KUSTOMIZE=$(shell which kustomize) +endif # In CircleCI, the linux binary will be attached from a previous step at pkg/bin/linux_amd64/. This make target # should only run in CI and not locally. @@ -148,5 +199,9 @@ ifeq ($(CIRCLE_BRANCH), master) @docker tag $(CI_DEV_DOCKER_NAMESPACE)/$(CI_DEV_DOCKER_IMAGE_NAME):$(GIT_COMMIT) $(CI_DEV_DOCKER_NAMESPACE)/$(CI_DEV_DOCKER_IMAGE_NAME):latest @docker push $(CI_DEV_DOCKER_NAMESPACE)/$(CI_DEV_DOCKER_IMAGE_NAME):latest endif +ifeq ($(CIRCLE_BRANCH), crd-controller-base) + @docker tag $(CI_DEV_DOCKER_NAMESPACE)/$(CI_DEV_DOCKER_IMAGE_NAME):$(GIT_COMMIT) $(CI_DEV_DOCKER_NAMESPACE)/$(CI_DEV_DOCKER_IMAGE_NAME):crd-controller-base-latest + @docker push $(CI_DEV_DOCKER_NAMESPACE)/$(CI_DEV_DOCKER_IMAGE_NAME):crd-controller-base-latest +endif .PHONY: all bin clean dev dist docker-images go-build-image test tools ci.dev-docker diff --git a/PROJECT b/PROJECT new file mode 100644 index 0000000000..4ebb45c34e --- /dev/null +++ b/PROJECT @@ -0,0 +1,7 @@ +# 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 +version: 3-alpha +plugins: + go.operator-sdk.io/v2-alpha: {} diff --git a/api/common/common.go b/api/common/common.go new file mode 100644 index 0000000000..e7c94637a8 --- /dev/null +++ b/api/common/common.go @@ -0,0 +1,19 @@ +// Package common holds code that isn't tied to a particular CRD version or type. +package common + +const ( + ServiceDefaults string = "servicedefaults" + ProxyDefaults string = "proxydefaults" + ServiceResolver string = "serviceresolver" + ServiceRouter string = "servicerouter" + ServiceSplitter string = "servicesplitter" + ServiceIntentions string = "serviceintentions" + + Global string = "global" + DefaultConsulNamespace string = "default" + WildcardNamespace string = "*" + + SourceKey string = "external-source" + DatacenterKey string = "consul.hashicorp.com/source-datacenter" + SourceValue string = "kubernetes" +) diff --git a/api/common/configentry.go b/api/common/configentry.go new file mode 100644 index 0000000000..40fbd604f4 --- /dev/null +++ b/api/common/configentry.go @@ -0,0 +1,60 @@ +package common + +import ( + "github.com/hashicorp/consul/api" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// ConfigEntryResource is a generic config entry custom resource. It is implemented +// by each config entry type so that they can be acted upon generically. +// It is not tied to a specific CRD version. +type ConfigEntryResource interface { + // GetObjectMeta returns object meta. + GetObjectMeta() metav1.ObjectMeta + // AddFinalizer adds a finalizer to the list of finalizers. + AddFinalizer(name string) + // RemoveFinalizer removes this finalizer from the list. + RemoveFinalizer(name string) + // Finalizers returns the list of finalizers for this object. + Finalizers() []string + // ConsulKind returns the Consul config entry kind, i.e. service-defaults, not + // servicedefaults. + ConsulKind() string + // ConsulGlobalResource returns if the resource exists in the default + // Consul namespace only. + ConsulGlobalResource() bool + // ConsulMirroringNS returns the Consul namespace that the config entry should + // be created in if namespaces and mirroring are enabled. + ConsulMirroringNS() string + // KubeKind returns the Kube config entry kind, i.e. servicedefaults, not + // service-defaults. + KubeKind() string + // ConsulName returns the name of the config entry as saved in Consul. + // This may be different than KubernetesName() in the case of a ServiceIntentions + // config entry. + ConsulName() string + // KubernetesName returns the name of the Kubernetes resource. + KubernetesName() string + // SetSyncedCondition updates the synced condition. + SetSyncedCondition(status corev1.ConditionStatus, reason, message string) + // SyncedCondition gets the synced condition. + SyncedCondition() (status corev1.ConditionStatus, reason, message string) + // SyncedConditionStatus returns the status of the synced condition. + SyncedConditionStatus() corev1.ConditionStatus + // ToConsul converts the resource to the corresponding Consul API definition. + // Its return type is the generic ConfigEntry but a specific config entry + // type should be constructed e.g. ServiceConfigEntry. + ToConsul(datacenter string) api.ConfigEntry + // MatchesConsul returns true if the resource has the same fields as the Consul + // config entry. + MatchesConsul(candidate api.ConfigEntry) bool + // GetObjectKind should be implemented by the generated code. + GetObjectKind() schema.ObjectKind + // DeepCopyObject should be implemented by the generated code. + DeepCopyObject() runtime.Object + // Validate returns an error if the resource is invalid. + Validate() error +} diff --git a/api/common/configentry_webhook.go b/api/common/configentry_webhook.go new file mode 100644 index 0000000000..e78182183c --- /dev/null +++ b/api/common/configentry_webhook.go @@ -0,0 +1,60 @@ +package common + +import ( + "context" + "fmt" + "net/http" + + "github.com/go-logr/logr" + "k8s.io/api/admission/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// ConfigEntryLister is implemented by CRD-specific webhooks. +type ConfigEntryLister interface { + // List returns all resources of this type across all namespaces in a + // Kubernetes cluster. + List(ctx context.Context) ([]ConfigEntryResource, error) +} + +// ValidateConfigEntry validates cfgEntry. It is a generic method that +// can be used by all CRD-specific validators. +// Callers should pass themselves as validator and kind should be the custom +// resource name, e.g. "ServiceDefaults". +func ValidateConfigEntry( + ctx context.Context, + req admission.Request, + logger logr.Logger, + configEntryLister ConfigEntryLister, + cfgEntry ConfigEntryResource, + enableConsulNamespaces bool, + nsMirroring bool) admission.Response { + + // On create we need to validate that there isn't already a resource with + // the same name in a different namespace if we're need to mapping all Kube + // resources to a single Consul namespace. The only case where we're not + // mapping all kube resources to a single Consul namespace is when we + // are running Consul enterprise with namespace mirroring. + singleConsulDestNS := !(enableConsulNamespaces && nsMirroring) + if req.Operation == v1beta1.Create && singleConsulDestNS { + logger.Info("validate create", "name", cfgEntry.KubernetesName()) + + list, err := configEntryLister.List(ctx) + if err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + for _, item := range list { + if item.KubernetesName() == cfgEntry.KubernetesName() { + return admission.Errored(http.StatusBadRequest, + fmt.Errorf("%s resource with name %q is already defined – all %s resources must have unique names across namespaces", + cfgEntry.KubeKind(), + cfgEntry.KubernetesName(), + cfgEntry.KubeKind())) + } + } + } + if err := cfgEntry.Validate(); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + return admission.Allowed(fmt.Sprintf("valid %s request", cfgEntry.KubeKind())) +} diff --git a/api/common/configentry_webhook_test.go b/api/common/configentry_webhook_test.go new file mode 100644 index 0000000000..8fc062df8f --- /dev/null +++ b/api/common/configentry_webhook_test.go @@ -0,0 +1,205 @@ +package common + +import ( + "context" + "encoding/json" + "errors" + "testing" + + logrtest "github.com/go-logr/logr/testing" + capi "github.com/hashicorp/consul/api" + "github.com/stretchr/testify/require" + "k8s.io/api/admission/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +func TestValidateConfigEntry(t *testing.T) { + otherNS := "other" + + cases := map[string]struct { + existingResources []ConfigEntryResource + newResource ConfigEntryResource + enableNamespaces bool + nsMirroring bool + expAllow bool + expErrMessage string + }{ + "no duplicates, valid": { + existingResources: nil, + newResource: &mockConfigEntry{ + MockName: "foo", + MockNamespace: otherNS, + Valid: true, + }, + expAllow: true, + }, + "no duplicates, invalid": { + existingResources: nil, + newResource: &mockConfigEntry{ + MockName: "foo", + MockNamespace: otherNS, + Valid: false, + }, + expAllow: false, + expErrMessage: "invalid", + }, + "duplicate name": { + existingResources: []ConfigEntryResource{&mockConfigEntry{ + MockName: "foo", + MockNamespace: "default", + }}, + newResource: &mockConfigEntry{ + MockName: "foo", + MockNamespace: otherNS, + Valid: true, + }, + expAllow: false, + expErrMessage: "mockkind resource with name \"foo\" is already defined – all mockkind resources must have unique names across namespaces", + }, + "duplicate name, namespaces enabled": { + existingResources: []ConfigEntryResource{&mockConfigEntry{ + MockName: "foo", + MockNamespace: "default", + }}, + newResource: &mockConfigEntry{ + MockName: "foo", + MockNamespace: otherNS, + Valid: true, + }, + enableNamespaces: true, + expAllow: false, + expErrMessage: "mockkind resource with name \"foo\" is already defined – all mockkind resources must have unique names across namespaces", + }, + "duplicate name, namespaces enabled, mirroring enabled": { + existingResources: []ConfigEntryResource{&mockConfigEntry{ + MockName: "foo", + MockNamespace: "default", + }}, + newResource: &mockConfigEntry{ + MockName: "foo", + MockNamespace: otherNS, + Valid: true, + }, + enableNamespaces: true, + nsMirroring: true, + expAllow: true, + }, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + marshalledRequestObject, err := json.Marshal(c.newResource) + require.NoError(t, err) + + lister := &mockConfigEntryLister{ + Resources: c.existingResources, + } + response := ValidateConfigEntry(ctx, admission.Request{ + AdmissionRequest: v1beta1.AdmissionRequest{ + Name: c.newResource.KubernetesName(), + Namespace: otherNS, + Operation: v1beta1.Create, + Object: runtime.RawExtension{ + Raw: marshalledRequestObject, + }, + }, + }, + logrtest.TestLogger{T: t}, + lister, + c.newResource, + c.enableNamespaces, + c.nsMirroring) + require.Equal(t, c.expAllow, response.Allowed) + if c.expErrMessage != "" { + require.Equal(t, c.expErrMessage, response.AdmissionResponse.Result.Message) + } + }) + } +} + +type mockConfigEntryLister struct { + Resources []ConfigEntryResource +} + +func (in *mockConfigEntryLister) List(_ context.Context) ([]ConfigEntryResource, error) { + return in.Resources, nil +} + +type mockConfigEntry struct { + MockName string + MockNamespace string + Valid bool +} + +func (in *mockConfigEntry) KubernetesName() string { + return in.MockName +} + +func (in *mockConfigEntry) ConsulMirroringNS() string { + return in.MockNamespace +} + +func (in *mockConfigEntry) GetObjectMeta() metav1.ObjectMeta { + return metav1.ObjectMeta{} +} + +func (in *mockConfigEntry) GetObjectKind() schema.ObjectKind { + return schema.EmptyObjectKind +} + +func (in *mockConfigEntry) DeepCopyObject() runtime.Object { + return in +} + +func (in *mockConfigEntry) ConsulGlobalResource() bool { + return false +} + +func (in *mockConfigEntry) AddFinalizer(_ string) {} + +func (in *mockConfigEntry) RemoveFinalizer(_ string) {} + +func (in *mockConfigEntry) Finalizers() []string { + return nil +} + +func (in *mockConfigEntry) ConsulKind() string { + return "mock-kind" +} + +func (in *mockConfigEntry) KubeKind() string { + return "mockkind" +} + +func (in *mockConfigEntry) ConsulName() string { + return in.MockName +} + +func (in *mockConfigEntry) SetSyncedCondition(_ corev1.ConditionStatus, _ string, _ string) {} + +func (in *mockConfigEntry) SyncedCondition() (status corev1.ConditionStatus, reason string, message string) { + return corev1.ConditionTrue, "", "" +} + +func (in *mockConfigEntry) SyncedConditionStatus() corev1.ConditionStatus { + return corev1.ConditionTrue +} + +func (in *mockConfigEntry) ToConsul(string) capi.ConfigEntry { + return &capi.ServiceConfigEntry{} +} + +func (in *mockConfigEntry) Validate() error { + if !in.Valid { + return errors.New("invalid") + } + return nil +} + +func (in *mockConfigEntry) MatchesConsul(_ capi.ConfigEntry) bool { + return false +} diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go new file mode 100644 index 0000000000..b6054efb6f --- /dev/null +++ b/api/v1alpha1/groupversion_info.go @@ -0,0 +1,22 @@ +// Package v1alpha1 contains API Schema definitions for the consul.hashicorp.com v1alpha1 API group +// +kubebuilder:object:generate=true +// +groupName=consul.hashicorp.com +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +const ConsulHashicorpGroup string = "consul.hashicorp.com" + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "consul.hashicorp.com", Version: "v1alpha1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v1alpha1/proxydefaults_types.go b/api/v1alpha1/proxydefaults_types.go new file mode 100644 index 0000000000..c720d61ff2 --- /dev/null +++ b/api/v1alpha1/proxydefaults_types.go @@ -0,0 +1,205 @@ +package v1alpha1 + +import ( + "encoding/json" + "fmt" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/consul-k8s/api/common" + "github.com/hashicorp/consul/api" + 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 ( + ProxyDefaultsKubeKind string = "proxydefaults" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// ProxyDefaults is the Schema for the proxydefaults API +// +kubebuilder:printcolumn:name="Synced",type="string",JSONPath=".status.conditions[?(@.type==\"Synced\")].status",description="The sync status of the resource with Consul" +type ProxyDefaults struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec ProxyDefaultsSpec `json:"spec,omitempty"` + Status `json:"status,omitempty"` +} + +// RawMessage for Config based on recommendation here: https://github.com/kubernetes-sigs/controller-tools/issues/294#issuecomment-518380816 + +// ProxyDefaultsSpec defines the desired state of ProxyDefaults +type ProxyDefaultsSpec struct { + // Config is an arbitrary map of configuration values used by Connect proxies. + // Any values that your proxy allows can be configured globally here. + // Supports JSON config values. See https://www.consul.io/docs/connect/proxies/envoy#configuration-formatting + Config json.RawMessage `json:"config,omitempty"` + // MeshGateway controls the default mesh gateway configuration for this service. + MeshGateway MeshGatewayConfig `json:"meshGateway,omitempty"` + // Expose controls the default expose path configuration for Envoy. + Expose ExposeConfig `json:"expose,omitempty"` +} + +func (in *ProxyDefaults) GetObjectMeta() metav1.ObjectMeta { + return in.ObjectMeta +} + +func (in *ProxyDefaults) AddFinalizer(name string) { + in.ObjectMeta.Finalizers = append(in.Finalizers(), name) +} + +func (in *ProxyDefaults) RemoveFinalizer(name string) { + var newFinalizers []string + for _, oldF := range in.Finalizers() { + if oldF != name { + newFinalizers = append(newFinalizers, oldF) + } + } + in.ObjectMeta.Finalizers = newFinalizers + +} + +func (in *ProxyDefaults) Finalizers() []string { + return in.ObjectMeta.Finalizers +} + +func (in *ProxyDefaults) ConsulKind() string { + return capi.ProxyDefaults +} + +func (in *ProxyDefaults) ConsulMirroringNS() string { + return common.DefaultConsulNamespace +} + +func (in *ProxyDefaults) KubeKind() string { + return ProxyDefaultsKubeKind +} + +func (in *ProxyDefaults) 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 *ProxyDefaults) SyncedConditionStatus() corev1.ConditionStatus { + cond := in.Status.GetCondition(ConditionSynced) + if cond == nil { + return corev1.ConditionUnknown + } + return cond.Status +} + +func (in *ProxyDefaults) ConsulName() string { + return in.ObjectMeta.Name +} + +func (in *ProxyDefaults) ConsulGlobalResource() bool { + return true +} + +func (in *ProxyDefaults) KubernetesName() string { + return in.ObjectMeta.Name +} + +func (in *ProxyDefaults) SetSyncedCondition(status corev1.ConditionStatus, reason string, message string) { + in.Status.Conditions = Conditions{ + { + Type: ConditionSynced, + Status: status, + LastTransitionTime: metav1.Now(), + Reason: reason, + Message: message, + }, + } +} + +func (in *ProxyDefaults) ToConsul(datacenter string) capi.ConfigEntry { + consulConfig := in.convertConfig() + return &capi.ProxyConfigEntry{ + Kind: in.ConsulKind(), + Name: in.ConsulName(), + MeshGateway: in.Spec.MeshGateway.toConsul(), + Expose: in.Spec.Expose.toConsul(), + Config: consulConfig, + Meta: meta(datacenter), + } +} + +func (in *ProxyDefaults) MatchesConsul(candidate api.ConfigEntry) bool { + configEntry, ok := candidate.(*capi.ProxyConfigEntry) + 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.ProxyConfigEntry{}, "Namespace", "Meta", "ModifyIndex", "CreateIndex"), cmpopts.IgnoreUnexported(), cmpopts.EquateEmpty()) +} + +func (in *ProxyDefaults) Validate() error { + var allErrs field.ErrorList + path := field.NewPath("spec") + + if err := in.Spec.MeshGateway.validate(path.Child("meshGateway")); err != nil { + allErrs = append(allErrs, err) + } + if err := in.validateConfig(path.Child("config")); err != nil { + allErrs = append(allErrs, err) + } + allErrs = append(allErrs, in.Spec.Expose.validate(path.Child("expose"))...) + if len(allErrs) > 0 { + return apierrors.NewInvalid( + schema.GroupKind{Group: ConsulHashicorpGroup, Kind: ProxyDefaultsKubeKind}, + in.KubernetesName(), allErrs) + } + + return nil +} + +// convertConfig converts the config of type json.RawMessage which is stored +// by the resource into type map[string]interface{} which is saved by the +// consul API. +func (in *ProxyDefaults) convertConfig() map[string]interface{} { + if in.Spec.Config == nil { + return nil + } + var outConfig map[string]interface{} + // We explicitly ignore the error returned by Unmarshall + // because validate() ensures that if we get to here that it + // won't return an error. + json.Unmarshal(in.Spec.Config, &outConfig) + return outConfig +} + +// validateConfig attempts to unmarshall the provided config into a map[string]interface{} +// and returns an error if the provided value for config isn't successfully unmarshalled +// and it implies the provided value is an invalid config. +func (in *ProxyDefaults) validateConfig(path *field.Path) *field.Error { + if in.Spec.Config == nil { + return nil + } + var outConfig map[string]interface{} + if err := json.Unmarshal(in.Spec.Config, &outConfig); err != nil { + return field.Invalid(path, in.Spec.Config, fmt.Sprintf(`must be valid map value: %s`, err)) + } + return nil +} + +// +kubebuilder:object:root=true + +// ProxyDefaultsList contains a list of ProxyDefaults +type ProxyDefaultsList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ProxyDefaults `json:"items"` +} + +func init() { + SchemeBuilder.Register(&ProxyDefaults{}, &ProxyDefaultsList{}) +} diff --git a/api/v1alpha1/proxydefaults_types_test.go b/api/v1alpha1/proxydefaults_types_test.go new file mode 100644 index 0000000000..53c5673290 --- /dev/null +++ b/api/v1alpha1/proxydefaults_types_test.go @@ -0,0 +1,359 @@ +package v1alpha1 + +import ( + "encoding/json" + "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" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// Test MatchesConsul for cases that should return true. +func TestProxyDefaults_MatchesConsul(t *testing.T) { + cases := map[string]struct { + Ours ProxyDefaults + Theirs capi.ConfigEntry + Matches bool + }{ + "empty fields matches": { + Ours: ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: common.Global, + }, + Spec: ProxyDefaultsSpec{}, + }, + Theirs: &capi.ProxyConfigEntry{ + Name: common.Global, + Kind: capi.ProxyDefaults, + Namespace: "default", + CreateIndex: 1, + ModifyIndex: 2, + Meta: map[string]string{ + common.SourceKey: common.SourceValue, + common.DatacenterKey: "datacenter", + }, + }, + Matches: true, + }, + "all fields set matches": { + Ours: ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: common.Global, + }, + Spec: ProxyDefaultsSpec{ + Config: json.RawMessage(`{"envoy_tracing_json": "{\"http\":{\"name\":\"envoy.zipkin\",\"config\":{\"collector_cluster\":\"zipkin\",\"collector_endpoint\":\"/api/v1/spans\",\"shared_span_context\":false}}}"}`), + MeshGateway: MeshGatewayConfig{ + Mode: "local", + }, + Expose: ExposeConfig{ + Checks: true, + Paths: []ExposePath{ + { + ListenerPort: 80, + Path: "/test/path", + LocalPathPort: 42, + Protocol: "tcp", + }, + { + ListenerPort: 8080, + Path: "/root/test/path", + LocalPathPort: 4201, + Protocol: "https", + }, + }, + }, + }, + }, + Theirs: &capi.ProxyConfigEntry{ + Kind: capi.ProxyDefaults, + Name: common.Global, + Config: map[string]interface{}{ + "envoy_tracing_json": "{\"http\":{\"name\":\"envoy.zipkin\",\"config\":{\"collector_cluster\":\"zipkin\",\"collector_endpoint\":\"/api/v1/spans\",\"shared_span_context\":false}}}", + }, + MeshGateway: capi.MeshGatewayConfig{ + Mode: capi.MeshGatewayModeLocal, + }, + Expose: capi.ExposeConfig{ + Checks: true, + Paths: []capi.ExposePath{ + { + ListenerPort: 80, + Path: "/test/path", + LocalPathPort: 42, + Protocol: "tcp", + }, + { + ListenerPort: 8080, + Path: "/root/test/path", + LocalPathPort: 4201, + Protocol: "https", + }, + }, + }, + }, + Matches: true, + }, + "mismatched types does not match": { + Ours: ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: common.Global, + }, + Spec: ProxyDefaultsSpec{}, + }, + Theirs: &capi.ServiceConfigEntry{ + Name: common.Global, + Kind: capi.ProxyDefaults, + }, + 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 TestProxyDefaults_ToConsul(t *testing.T) { + cases := map[string]struct { + Ours ProxyDefaults + Exp *capi.ProxyConfigEntry + }{ + "empty fields": { + Ours: ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ProxyDefaultsSpec{}, + }, + Exp: &capi.ProxyConfigEntry{ + Name: "name", + Kind: capi.ProxyDefaults, + Meta: map[string]string{ + common.SourceKey: common.SourceValue, + common.DatacenterKey: "datacenter", + }, + }, + }, + "every field set": { + Ours: ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ProxyDefaultsSpec{ + Config: json.RawMessage(`{"envoy_tracing_json": "{\"http\":{\"name\":\"envoy.zipkin\",\"config\":{\"collector_cluster\":\"zipkin\",\"collector_endpoint\":\"/api/v1/spans\",\"shared_span_context\":false}}}"}`), + MeshGateway: MeshGatewayConfig{ + Mode: "remote", + }, + Expose: ExposeConfig{ + Checks: true, + Paths: []ExposePath{ + { + ListenerPort: 80, + Path: "/default", + LocalPathPort: 9091, + Protocol: "tcp", + }, + { + ListenerPort: 8080, + Path: "/v2", + LocalPathPort: 3001, + Protocol: "https", + }, + }, + }, + }, + }, + Exp: &capi.ProxyConfigEntry{ + Kind: capi.ProxyDefaults, + Name: "name", + Namespace: "", + Config: map[string]interface{}{ + "envoy_tracing_json": "{\"http\":{\"name\":\"envoy.zipkin\",\"config\":{\"collector_cluster\":\"zipkin\",\"collector_endpoint\":\"/api/v1/spans\",\"shared_span_context\":false}}}", + }, + MeshGateway: capi.MeshGatewayConfig{ + Mode: capi.MeshGatewayModeRemote, + }, + Expose: capi.ExposeConfig{ + Checks: true, + Paths: []capi.ExposePath{ + { + ListenerPort: 80, + Path: "/default", + LocalPathPort: 9091, + Protocol: "tcp", + }, + { + ListenerPort: 8080, + Path: "/v2", + LocalPathPort: 3001, + Protocol: "https", + }, + }, + }, + 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") + resolver, ok := act.(*capi.ProxyConfigEntry) + require.True(t, ok, "could not cast") + require.Equal(t, c.Exp, resolver) + }) + } +} + +func TestProxyDefaults_ValidateConfigValid(t *testing.T) { + cases := map[string]json.RawMessage{ + "envoy_tracing_json": json.RawMessage(`{"envoy_tracing_json": "{\"http\":{\"name\":\"envoy.zipkin\",\"config\":{\"collector_cluster\":\"zipkin\",\"collector_endpoint\":\"/api/v1/spans\",\"shared_span_context\":false}}}"}`), + "protocol": json.RawMessage(`{"protocol": "http"}`), + "members": json.RawMessage(`{"members": 3}`), + "envoy_tracing_json & protocol": json.RawMessage(`{"envoy_tracing_json": "{\"http\":{\"name\":\"envoy.zipkin\",\"config\":{\"collector_cluster\":\"zipkin\",\"collector_endpoint\":\"/api/v1/spans\",\"shared_span_context\":false}}}","protocol": "http"}`), + "envoy_tracing_json & members": json.RawMessage(`{"envoy_tracing_json": "{\"http\":{\"name\":\"envoy.zipkin\",\"config\":{\"collector_cluster\":\"zipkin\",\"collector_endpoint\":\"/api/v1/spans\",\"shared_span_context\":false}}}","members": 3}`), + "protocol & members": json.RawMessage(`{"protocol": "https","members": 3}`), + "envoy_tracing_json, protocol & members": json.RawMessage(`{"envoy_tracing_json": "{\"http\":{\"name\":\"envoy.zipkin\",\"config\":{\"collector_cluster\":\"zipkin\",\"collector_endpoint\":\"/api/v1/spans\",\"shared_span_context\":false}}}","protocol": "http", "members": 3}`), + } + for name, c := range cases { + proxyDefaults := ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: common.Global, + }, + Spec: ProxyDefaultsSpec{ + Config: c, + }, + } + t.Run(name, func(t *testing.T) { + require.Nil(t, proxyDefaults.validateConfig(nil)) + }) + } +} + +func TestProxyDefaults_ValidateConfigInvalid(t *testing.T) { + cases := map[string]json.RawMessage{ + "non_map json": json.RawMessage(`"{\"http\":{\"name\":\"envoy.zipkin\",\"config\":{\"collector_cluster\":\"zipkin\",\"collector_endpoint\":\"/api/v1/spans\",\"shared_span_context\":false}}}"`), + "yaml": json.RawMessage(`protocol: http`), + "json array": json.RawMessage(`[1,2,3,4]`), + "json literal": json.RawMessage(`1`), + } + for name, c := range cases { + proxyDefaults := ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: common.Global, + }, + Spec: ProxyDefaultsSpec{ + Config: c, + }, + } + t.Run(name, func(t *testing.T) { + require.Contains(t, proxyDefaults.validateConfig(field.NewPath("spec")).Detail, "must be valid map value") + }) + } +} + +func TestProxyDefaults_AddFinalizer(t *testing.T) { + resolver := &ProxyDefaults{} + resolver.AddFinalizer("finalizer") + require.Equal(t, []string{"finalizer"}, resolver.ObjectMeta.Finalizers) +} + +func TestProxyDefaults_RemoveFinalizer(t *testing.T) { + resolver := &ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Finalizers: []string{"f1", "f2"}, + }, + } + resolver.RemoveFinalizer("f1") + require.Equal(t, []string{"f2"}, resolver.ObjectMeta.Finalizers) +} + +func TestProxyDefaults_SetSyncedCondition(t *testing.T) { + resolver := &ProxyDefaults{} + resolver.SetSyncedCondition(corev1.ConditionTrue, "reason", "message") + + require.Equal(t, corev1.ConditionTrue, resolver.Status.Conditions[0].Status) + require.Equal(t, "reason", resolver.Status.Conditions[0].Reason) + require.Equal(t, "message", resolver.Status.Conditions[0].Message) + now := metav1.Now() + require.True(t, resolver.Status.Conditions[0].LastTransitionTime.Before(&now)) +} + +func TestProxyDefaults_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) { + resolver := &ProxyDefaults{ + Status: Status{ + Conditions: []Condition{{ + Type: ConditionSynced, + Status: status, + }}, + }, + } + + require.Equal(t, status, resolver.SyncedConditionStatus()) + }) + } +} + +func TestProxyDefaults_GetConditionWhenStatusNil(t *testing.T) { + require.Nil(t, (&ProxyDefaults{}).GetCondition(ConditionSynced)) +} + +func TestProxyDefaults_SyncedConditionStatusWhenStatusNil(t *testing.T) { + require.Equal(t, corev1.ConditionUnknown, (&ProxyDefaults{}).SyncedConditionStatus()) +} + +func TestProxyDefaults_SyncedConditionWhenStatusNil(t *testing.T) { + status, reason, message := (&ProxyDefaults{}).SyncedCondition() + require.Equal(t, corev1.ConditionUnknown, status) + require.Equal(t, "", reason) + require.Equal(t, "", message) +} + +func TestProxyDefaults_ConsulKind(t *testing.T) { + require.Equal(t, capi.ProxyDefaults, (&ProxyDefaults{}).ConsulKind()) +} + +func TestProxyDefaults_KubeKind(t *testing.T) { + require.Equal(t, "proxydefaults", (&ProxyDefaults{}).KubeKind()) +} + +func TestProxyDefaults_ConsulName(t *testing.T) { + require.Equal(t, "foo", (&ProxyDefaults{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}).ConsulName()) +} + +func TestProxyDefaults_KubernetesName(t *testing.T) { + require.Equal(t, "foo", (&ProxyDefaults{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}).KubernetesName()) +} + +func TestProxyDefaults_ConsulNamespace(t *testing.T) { + require.Equal(t, common.DefaultConsulNamespace, (&ProxyDefaults{ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "bar"}}).ConsulMirroringNS()) +} + +func TestProxyDefaults_ConsulGlobalResource(t *testing.T) { + require.True(t, (&ProxyDefaults{}).ConsulGlobalResource()) +} + +func TestProxyDefaults_ObjectMeta(t *testing.T) { + meta := metav1.ObjectMeta{ + Name: "name", + Namespace: "namespace", + } + proxyDefaults := &ProxyDefaults{ + ObjectMeta: meta, + } + require.Equal(t, meta, proxyDefaults.GetObjectMeta()) +} diff --git a/api/v1alpha1/proxydefaults_webhook.go b/api/v1alpha1/proxydefaults_webhook.go new file mode 100644 index 0000000000..1451bf274c --- /dev/null +++ b/api/v1alpha1/proxydefaults_webhook.go @@ -0,0 +1,73 @@ +package v1alpha1 + +import ( + "context" + "fmt" + "net/http" + + "github.com/go-logr/logr" + "github.com/hashicorp/consul-k8s/api/common" + capi "github.com/hashicorp/consul/api" + "k8s.io/api/admission/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// +kubebuilder:object:generate=false + +type ProxyDefaultsWebhook struct { + client.Client + ConsulClient *capi.Client + Logger logr.Logger + decoder *admission.Decoder + EnableConsulNamespaces bool + EnableNSMirroring bool +} + +// 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-proxydefaults,mutating=true,failurePolicy=fail,groups=consul.hashicorp.com,resources=proxydefaults,versions=v1alpha1,name=mutate-proxydefaults.consul.hashicorp.com,webhookVersions=v1beta1,sideEffects=None + +func (v *ProxyDefaultsWebhook) Handle(ctx context.Context, req admission.Request) admission.Response { + var proxyDefaults ProxyDefaults + var proxyDefaultsList ProxyDefaultsList + err := v.decoder.Decode(req, &proxyDefaults) + if err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + if req.Operation == v1beta1.Create { + v.Logger.Info("validate create", "name", proxyDefaults.KubernetesName()) + + if proxyDefaults.KubernetesName() != common.Global { + return admission.Errored(http.StatusBadRequest, + fmt.Errorf(`%s resource name must be "%s"`, + proxyDefaults.KubeKind(), common.Global)) + } + + if err := v.Client.List(ctx, &proxyDefaultsList); err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + + if len(proxyDefaultsList.Items) > 0 { + return admission.Errored(http.StatusBadRequest, + fmt.Errorf("%s resource already defined - only one global entry is supported", + proxyDefaults.KubeKind())) + } + } + + if err := proxyDefaults.Validate(); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + return admission.Allowed(fmt.Sprintf("valid %s request", proxyDefaults.KubeKind())) +} + +func (v *ProxyDefaultsWebhook) InjectDecoder(d *admission.Decoder) error { + v.decoder = d + return nil +} diff --git a/api/v1alpha1/proxydefaults_webhook_test.go b/api/v1alpha1/proxydefaults_webhook_test.go new file mode 100644 index 0000000000..00fd23c2e1 --- /dev/null +++ b/api/v1alpha1/proxydefaults_webhook_test.go @@ -0,0 +1,115 @@ +package v1alpha1 + +import ( + "context" + "encoding/json" + "testing" + + logrtest "github.com/go-logr/logr/testing" + "github.com/hashicorp/consul-k8s/api/common" + "github.com/stretchr/testify/require" + "k8s.io/api/admission/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +func TestValidateProxyDefault(t *testing.T) { + otherNS := "other" + + cases := map[string]struct { + existingResources []runtime.Object + newResource *ProxyDefaults + expAllow bool + expErrMessage string + }{ + "no duplicates, valid": { + existingResources: nil, + newResource: &ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: common.Global, + }, + Spec: ProxyDefaultsSpec{}, + }, + expAllow: true, + }, + "invalid config": { + existingResources: nil, + newResource: &ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: common.Global, + }, + Spec: ProxyDefaultsSpec{ + Config: json.RawMessage("1"), + }, + }, + expAllow: false, + // This error message is because the value "1" is valid JSON but is an invalid map + expErrMessage: "proxydefaults.consul.hashicorp.com \"global\" is invalid: spec.config: Invalid value: json.RawMessage{0x31}: must be valid map value: json: cannot unmarshal number into Go value of type map[string]interface {}", + }, + "proxy default exists": { + existingResources: []runtime.Object{&ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: common.Global, + }, + }}, + newResource: &ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: common.Global, + }, + Spec: ProxyDefaultsSpec{ + MeshGateway: MeshGatewayConfig{ + Mode: "local", + }, + }, + }, + expAllow: false, + expErrMessage: "proxydefaults resource already defined - only one global entry is supported", + }, + "name not global": { + existingResources: []runtime.Object{}, + newResource: &ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "local", + }, + }, + expAllow: false, + expErrMessage: "proxydefaults resource name must be \"global\"", + }, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + marshalledRequestObject, err := json.Marshal(c.newResource) + require.NoError(t, err) + s := runtime.NewScheme() + s.AddKnownTypes(GroupVersion, &ProxyDefaults{}, &ProxyDefaultsList{}) + client := fake.NewFakeClientWithScheme(s, c.existingResources...) + decoder, err := admission.NewDecoder(s) + require.NoError(t, err) + + validator := &ProxyDefaultsWebhook{ + Client: client, + ConsulClient: nil, + Logger: logrtest.TestLogger{T: t}, + decoder: decoder, + } + response := validator.Handle(ctx, admission.Request{ + AdmissionRequest: v1beta1.AdmissionRequest{ + Name: c.newResource.KubernetesName(), + Namespace: otherNS, + Operation: v1beta1.Create, + Object: runtime.RawExtension{ + Raw: marshalledRequestObject, + }, + }, + }) + + require.Equal(t, c.expAllow, response.Allowed) + if c.expErrMessage != "" { + require.Equal(t, c.expErrMessage, response.AdmissionResponse.Result.Message) + } + }) + } +} diff --git a/api/v1alpha1/servicedefaults_types.go b/api/v1alpha1/servicedefaults_types.go new file mode 100644 index 0000000000..5a9d7a8705 --- /dev/null +++ b/api/v1alpha1/servicedefaults_types.go @@ -0,0 +1,237 @@ +package v1alpha1 + +import ( + "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 ( + ServiceDefaultsKubeKind string = "servicedefaults" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// ServiceDefaults is the Schema for the servicedefaults API +// +kubebuilder:printcolumn:name="Synced",type="string",JSONPath=".status.conditions[?(@.type==\"Synced\")].status",description="The sync status of the resource with Consul" +type ServiceDefaults struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec ServiceDefaultsSpec `json:"spec,omitempty"` + Status `json:"status,omitempty"` +} + +// ServiceDefaultsSpec defines the desired state of ServiceDefaults +type ServiceDefaultsSpec struct { + // Protocol sets the protocol of the service. This is used by Connect proxies for + // things like observability features and to unlock usage of the + // service-splitter and service-router config entries for a service. + Protocol string `json:"protocol,omitempty"` + // MeshGateway controls the default mesh gateway configuration for this service. + MeshGateway MeshGatewayConfig `json:"meshGateway,omitempty"` + // Expose controls the default expose path configuration for Envoy. + Expose ExposeConfig `json:"expose,omitempty"` + // ExternalSNI is an optional setting that allows for the TLS SNI value + // to be changed to a non-connect value when federating with an external system. + ExternalSNI string `json:"externalSNI,omitempty"` +} + +func (in *ServiceDefaults) ConsulKind() string { + return capi.ServiceDefaults +} + +func (in *ServiceDefaults) ConsulMirroringNS() string { + return in.Namespace +} + +func (in *ServiceDefaults) KubeKind() string { + return ServiceDefaultsKubeKind +} + +func (in *ServiceDefaults) GetObjectMeta() metav1.ObjectMeta { + return in.ObjectMeta +} + +func (in *ServiceDefaults) AddFinalizer(name string) { + in.ObjectMeta.Finalizers = append(in.Finalizers(), name) +} + +func (in *ServiceDefaults) RemoveFinalizer(name string) { + var newFinalizers []string + for _, oldF := range in.Finalizers() { + if oldF != name { + newFinalizers = append(newFinalizers, oldF) + } + } + in.ObjectMeta.Finalizers = newFinalizers +} + +func (in *ServiceDefaults) Finalizers() []string { + return in.ObjectMeta.Finalizers +} + +func (in *ServiceDefaults) ConsulName() string { + return in.ObjectMeta.Name +} + +func (in *ServiceDefaults) KubernetesName() string { + return in.ObjectMeta.Name +} + +func (in *ServiceDefaults) SetSyncedCondition(status corev1.ConditionStatus, reason string, message string) { + in.Status.Conditions = Conditions{ + { + Type: ConditionSynced, + Status: status, + LastTransitionTime: metav1.Now(), + Reason: reason, + Message: message, + }, + } +} + +func (in *ServiceDefaults) SyncedCondition() (status corev1.ConditionStatus, reason string, message string) { + cond := in.Status.GetCondition(ConditionSynced) + if cond == nil { + return corev1.ConditionUnknown, "", "" + } + return cond.Status, cond.Reason, cond.Message +} + +func (in *ServiceDefaults) SyncedConditionStatus() corev1.ConditionStatus { + condition := in.Status.GetCondition(ConditionSynced) + if condition == nil { + return corev1.ConditionUnknown + } + return condition.Status +} + +// +kubebuilder:object:root=true + +// ServiceDefaultsList contains a list of ServiceDefaults +type ServiceDefaultsList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ServiceDefaults `json:"items"` +} + +func init() { + SchemeBuilder.Register(&ServiceDefaults{}, &ServiceDefaultsList{}) +} + +// ToConsul converts the entry into it's Consul equivalent struct. +func (in *ServiceDefaults) ToConsul(datacenter string) capi.ConfigEntry { + return &capi.ServiceConfigEntry{ + Kind: in.ConsulKind(), + Name: in.ConsulName(), + Protocol: in.Spec.Protocol, + MeshGateway: in.Spec.MeshGateway.toConsul(), + Expose: in.Spec.Expose.toConsul(), + ExternalSNI: in.Spec.ExternalSNI, + Meta: meta(datacenter), + } +} + +// Validate validates the fields provided in the spec of the ServiceDefaults and +// returns an error which lists all invalid fields in the resource spec. +func (in *ServiceDefaults) Validate() error { + var allErrs field.ErrorList + path := field.NewPath("spec") + + if err := in.Spec.MeshGateway.validate(path.Child("meshGateway")); err != nil { + allErrs = append(allErrs, err) + } + allErrs = append(allErrs, in.Spec.Expose.validate(path.Child("expose"))...) + + if len(allErrs) > 0 { + return apierrors.NewInvalid( + schema.GroupKind{Group: ConsulHashicorpGroup, Kind: ServiceDefaultsKubeKind}, + in.KubernetesName(), allErrs) + } + + return nil +} + +// MatchesConsul returns true if entry has the same config as this struct. +func (in *ServiceDefaults) MatchesConsul(candidate capi.ConfigEntry) bool { + configEntry, ok := candidate.(*capi.ServiceConfigEntry) + 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.ServiceConfigEntry{}, "Namespace", "Meta", "ModifyIndex", "CreateIndex"), cmpopts.IgnoreUnexported(), cmpopts.EquateEmpty()) +} + +func (in *ServiceDefaults) ConsulGlobalResource() bool { + return false +} + +// ExposeConfig describes HTTP paths to expose through Envoy outside of Connect. +// Users can expose individual paths and/or all HTTP/GRPC paths for checks. +type ExposeConfig struct { + // Checks defines whether paths associated with Consul checks will be exposed. + // This flag triggers exposing all HTTP and GRPC check paths registered for the service. + Checks bool `json:"checks,omitempty"` + + // Paths is the list of paths exposed through the proxy. + Paths []ExposePath `json:"paths,omitempty"` +} + +type ExposePath struct { + // ListenerPort defines the port of the proxy's listener for exposed paths. + ListenerPort int `json:"listenerPort,omitempty"` + + // Path is the path to expose through the proxy, ie. "/metrics". + Path string `json:"path,omitempty"` + + // LocalPathPort is the port that the service is listening on for the given path. + LocalPathPort int `json:"localPathPort,omitempty"` + + // Protocol describes the upstream's service protocol. + // Valid values are "http" and "http2", defaults to "http". + Protocol string `json:"protocol,omitempty"` +} + +// toConsul returns the ExposeConfig for the entry +func (e ExposeConfig) toConsul() capi.ExposeConfig { + var paths []capi.ExposePath + for _, path := range e.Paths { + paths = append(paths, capi.ExposePath{ + ListenerPort: path.ListenerPort, + Path: path.Path, + LocalPathPort: path.LocalPathPort, + Protocol: path.Protocol, + }) + } + return capi.ExposeConfig{ + Checks: e.Checks, + Paths: paths, + } +} + +func (e ExposeConfig) validate(path *field.Path) []*field.Error { + var errs field.ErrorList + protocols := []string{"http", "http2"} + for i, pathCfg := range e.Paths { + indexPath := path.Child("paths").Index(i) + if invalidPathPrefix(pathCfg.Path) { + errs = append(errs, field.Invalid( + indexPath.Child("path"), + pathCfg.Path, + `must begin with a '/'`)) + } + if !sliceContains(protocols, pathCfg.Protocol) { + errs = append(errs, field.Invalid( + indexPath.Child("protocol"), + pathCfg.Protocol, + notInSliceMessage(protocols))) + } + } + return errs +} diff --git a/api/v1alpha1/servicedefaults_types_test.go b/api/v1alpha1/servicedefaults_types_test.go new file mode 100644 index 0000000000..b6fd9eaa1b --- /dev/null +++ b/api/v1alpha1/servicedefaults_types_test.go @@ -0,0 +1,411 @@ +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 TestServiceDefaults_ToConsul(t *testing.T) { + cases := map[string]struct { + input *ServiceDefaults + expected *capi.ServiceConfigEntry + }{ + "empty fields": { + &ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: ServiceDefaultsSpec{}, + }, + &capi.ServiceConfigEntry{ + Name: "foo", + Kind: capi.ServiceDefaults, + Meta: map[string]string{ + common.SourceKey: common.SourceValue, + common.DatacenterKey: "datacenter", + }, + }, + }, + "every field set": { + &ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: ServiceDefaultsSpec{ + Protocol: "https", + MeshGateway: MeshGatewayConfig{ + Mode: "local", + }, + Expose: ExposeConfig{ + Checks: true, + Paths: []ExposePath{ + { + ListenerPort: 80, + Path: "/path", + LocalPathPort: 9000, + Protocol: "tcp", + }, + { + ListenerPort: 8080, + Path: "/another-path", + LocalPathPort: 9091, + Protocol: "http2", + }, + }, + }, + ExternalSNI: "external-sni", + }, + }, + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "https", + MeshGateway: capi.MeshGatewayConfig{ + Mode: capi.MeshGatewayModeLocal, + }, + Expose: capi.ExposeConfig{ + Checks: true, + Paths: []capi.ExposePath{ + { + ListenerPort: 80, + Path: "/path", + LocalPathPort: 9000, + Protocol: "tcp", + }, + { + ListenerPort: 8080, + Path: "/another-path", + LocalPathPort: 9091, + Protocol: "http2", + }, + }, + }, + ExternalSNI: "external-sni", + Meta: map[string]string{ + common.SourceKey: common.SourceValue, + common.DatacenterKey: "datacenter", + }, + }, + }, + } + + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + output := testCase.input.ToConsul("datacenter") + require.Equal(t, testCase.expected, output) + }) + } +} + +func TestServiceDefaults_MatchesConsul(t *testing.T) { + cases := map[string]struct { + internal *ServiceDefaults + consul capi.ConfigEntry + matches bool + }{ + "empty fields matches": { + &ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-test-service", + }, + Spec: ServiceDefaultsSpec{}, + }, + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "my-test-service", + Namespace: "namespace", + CreateIndex: 1, + ModifyIndex: 2, + Meta: map[string]string{ + common.SourceKey: common.SourceValue, + common.DatacenterKey: "datacenter", + }, + }, + true, + }, + "all fields populated matches": { + &ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-test-service", + }, + Spec: ServiceDefaultsSpec{ + Protocol: "http", + MeshGateway: MeshGatewayConfig{ + Mode: "remote", + }, + Expose: ExposeConfig{ + Paths: []ExposePath{ + { + ListenerPort: 8080, + Path: "/second/test/path", + LocalPathPort: 11, + Protocol: "https", + }, + { + ListenerPort: 80, + Path: "/test/path", + LocalPathPort: 42, + Protocol: "tcp", + }, + }, + }, + ExternalSNI: "sni-value", + }, + }, + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "my-test-service", + Protocol: "http", + MeshGateway: capi.MeshGatewayConfig{ + Mode: capi.MeshGatewayModeRemote, + }, + Expose: capi.ExposeConfig{ + Paths: []capi.ExposePath{ + { + ListenerPort: 8080, + Path: "/second/test/path", + LocalPathPort: 11, + Protocol: "https", + }, + { + ListenerPort: 80, + Path: "/test/path", + LocalPathPort: 42, + Protocol: "tcp", + }, + }, + }, + ExternalSNI: "sni-value", + }, + true, + }, + "mismatched types does not match": { + &ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-test-service", + }, + Spec: ServiceDefaultsSpec{}, + }, + &capi.ProxyConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "my-test-service", + Namespace: "namespace", + CreateIndex: 1, + ModifyIndex: 2, + }, + false, + }, + } + + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + require.Equal(t, testCase.matches, testCase.internal.MatchesConsul(testCase.consul)) + }) + } +} + +func TestServiceDefaults_Validate(t *testing.T) { + cases := map[string]struct { + input *ServiceDefaults + expectedErrMsg string + }{ + "valid": { + input: &ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + }, + Spec: ServiceDefaultsSpec{ + MeshGateway: MeshGatewayConfig{ + Mode: "remote", + }, + }, + }, + expectedErrMsg: "", + }, + "meshgateway.mode": { + &ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + }, + Spec: ServiceDefaultsSpec{ + MeshGateway: MeshGatewayConfig{ + Mode: "foobar", + }, + }, + }, + `servicedefaults.consul.hashicorp.com "my-service" is invalid: spec.meshGateway.mode: Invalid value: "foobar": must be one of "remote", "local", "none", ""`, + }, + "expose.paths[].protocol": { + &ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + }, + Spec: ServiceDefaultsSpec{ + Expose: ExposeConfig{ + Paths: []ExposePath{ + { + Protocol: "invalid-protocol", + Path: "/valid-path", + }, + }, + }, + }, + }, + `servicedefaults.consul.hashicorp.com "my-service" is invalid: spec.expose.paths[0].protocol: Invalid value: "invalid-protocol": must be one of "http", "http2"`, + }, + "expose.paths[].path": { + &ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + }, + Spec: ServiceDefaultsSpec{ + Expose: ExposeConfig{ + Paths: []ExposePath{ + { + Protocol: "http", + Path: "invalid-path", + }, + }, + }, + }, + }, + `servicedefaults.consul.hashicorp.com "my-service" is invalid: spec.expose.paths[0].path: Invalid value: "invalid-path": must begin with a '/'`, + }, + "multi-error": { + &ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + }, + Spec: ServiceDefaultsSpec{ + MeshGateway: MeshGatewayConfig{ + Mode: "invalid-mode", + }, + Expose: ExposeConfig{ + Paths: []ExposePath{ + { + Protocol: "invalid-protocol", + Path: "invalid-path", + }, + }, + }, + }, + }, + `servicedefaults.consul.hashicorp.com "my-service" is invalid: [spec.meshGateway.mode: Invalid value: "invalid-mode": must be one of "remote", "local", "none", "", spec.expose.paths[0].path: Invalid value: "invalid-path": must begin with a '/', spec.expose.paths[0].protocol: Invalid value: "invalid-protocol": must be one of "http", "http2"]`, + }, + } + + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + err := testCase.input.Validate() + if testCase.expectedErrMsg != "" { + require.EqualError(t, err, testCase.expectedErrMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestServiceDefaults_AddFinalizer(t *testing.T) { + serviceDefaults := &ServiceDefaults{} + serviceDefaults.AddFinalizer("finalizer") + require.Equal(t, []string{"finalizer"}, serviceDefaults.ObjectMeta.Finalizers) +} + +func TestServiceDefaults_RemoveFinalizer(t *testing.T) { + serviceDefaults := &ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Finalizers: []string{"f1", "f2"}, + }, + } + serviceDefaults.RemoveFinalizer("f1") + require.Equal(t, []string{"f2"}, serviceDefaults.ObjectMeta.Finalizers) +} + +func TestServiceDefaults_SetSyncedCondition(t *testing.T) { + serviceDefaults := &ServiceDefaults{} + serviceDefaults.SetSyncedCondition(corev1.ConditionTrue, "reason", "message") + + require.Equal(t, corev1.ConditionTrue, serviceDefaults.Status.Conditions[0].Status) + require.Equal(t, "reason", serviceDefaults.Status.Conditions[0].Reason) + require.Equal(t, "message", serviceDefaults.Status.Conditions[0].Message) + now := metav1.Now() + require.True(t, serviceDefaults.Status.Conditions[0].LastTransitionTime.Before(&now)) +} + +func TestServiceDefaults_GetSyncedConditionStatus(t *testing.T) { + cases := []corev1.ConditionStatus{ + corev1.ConditionUnknown, + corev1.ConditionFalse, + corev1.ConditionTrue, + } + for _, status := range cases { + t.Run(string(status), func(t *testing.T) { + serviceDefaults := &ServiceDefaults{ + Status: Status{ + Conditions: []Condition{{ + Type: ConditionSynced, + Status: status, + }}, + }, + } + + require.Equal(t, status, serviceDefaults.SyncedConditionStatus()) + }) + } +} + +func TestServiceDefaults_GetConditionWhenStatusNil(t *testing.T) { + require.Nil(t, (&ServiceDefaults{}).GetCondition(ConditionSynced)) +} + +func TestServiceDefaults_SyncedConditionStatusWhenStatusNil(t *testing.T) { + require.Equal(t, corev1.ConditionUnknown, (&ServiceDefaults{}).SyncedConditionStatus()) +} + +func TestServiceDefaults_SyncedConditionWhenStatusNil(t *testing.T) { + status, reason, message := (&ServiceDefaults{}).SyncedCondition() + require.Equal(t, corev1.ConditionUnknown, status) + require.Equal(t, "", reason) + require.Equal(t, "", message) +} + +func TestServiceDefaults_ConsulKind(t *testing.T) { + require.Equal(t, capi.ServiceDefaults, (&ServiceDefaults{}).ConsulKind()) +} + +func TestServiceDefaults_KubeKind(t *testing.T) { + require.Equal(t, "servicedefaults", (&ServiceDefaults{}).KubeKind()) +} + +func TestServiceDefaults_ConsulName(t *testing.T) { + require.Equal(t, "foo", (&ServiceDefaults{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}).ConsulName()) +} + +func TestServiceDefaults_KubernetesName(t *testing.T) { + require.Equal(t, "foo", (&ServiceDefaults{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}).ConsulName()) +} + +func TestServiceDefaults_ConsulNamespace(t *testing.T) { + require.Equal(t, "bar", (&ServiceDefaults{ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "bar"}}).ConsulMirroringNS()) +} + +func TestServiceDefaults_ConsulGlobalResource(t *testing.T) { + require.False(t, (&ServiceDefaults{}).ConsulGlobalResource()) +} + +func TestServiceDefaults_ObjectMeta(t *testing.T) { + meta := metav1.ObjectMeta{ + Name: "name", + Namespace: "namespace", + } + serviceDefaults := &ServiceDefaults{ + ObjectMeta: meta, + } + require.Equal(t, meta, serviceDefaults.GetObjectMeta()) +} diff --git a/api/v1alpha1/servicedefaults_webhook.go b/api/v1alpha1/servicedefaults_webhook.go new file mode 100644 index 0000000000..e45e8e5ad2 --- /dev/null +++ b/api/v1alpha1/servicedefaults_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 ServiceDefaultsWebhook 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-servicedefaults,mutating=true,failurePolicy=fail,groups=consul.hashicorp.com,resources=servicedefaults,versions=v1alpha1,name=mutate-servicedefaults.consul.hashicorp.com,webhookVersions=v1beta1,sideEffects=None + +func (v *ServiceDefaultsWebhook) Handle(ctx context.Context, req admission.Request) admission.Response { + var svcDefaults ServiceDefaults + err := v.decoder.Decode(req, &svcDefaults) + if err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + return common.ValidateConfigEntry(ctx, + req, + v.Logger, + v, + &svcDefaults, + v.EnableConsulNamespaces, + v.EnableNSMirroring) +} + +func (v *ServiceDefaultsWebhook) List(ctx context.Context) ([]common.ConfigEntryResource, error) { + var svcDefaultsList ServiceDefaultsList + if err := v.Client.List(ctx, &svcDefaultsList); err != nil { + return nil, err + } + var entries []common.ConfigEntryResource + for _, item := range svcDefaultsList.Items { + entries = append(entries, common.ConfigEntryResource(&item)) + } + return entries, nil +} + +func (v *ServiceDefaultsWebhook) InjectDecoder(d *admission.Decoder) error { + v.decoder = d + return nil +} diff --git a/api/v1alpha1/serviceintentions_types.go b/api/v1alpha1/serviceintentions_types.go new file mode 100644 index 0000000000..05c0c5cda6 --- /dev/null +++ b/api/v1alpha1/serviceintentions_types.go @@ -0,0 +1,329 @@ +package v1alpha1 + +import ( + "encoding/json" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/consul-k8s/api/common" + "github.com/hashicorp/consul/api" + 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" +) + +// ServiceIntentionsSpec defines the desired state of ServiceIntentions +type ServiceIntentionsSpec struct { + Destination Destination `json:"destination,omitempty"` + Sources SourceIntentions `json:"sources,omitempty"` +} + +type Destination struct { + Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` +} + +type SourceIntentions []*SourceIntention +type IntentionPermissions []*IntentionPermission +type IntentionHTTPHeaderPermissions []IntentionHTTPHeaderPermission + +type SourceIntention struct { + Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` + Action IntentionAction `json:"action,omitempty"` + Permissions IntentionPermissions `json:"permissions,omitempty"` + Description string `json:"description,omitempty"` +} + +type IntentionPermission struct { + Action IntentionAction `json:"action,omitempty"` + HTTP *IntentionHTTPPermission `json:"http,omitempty"` +} + +type IntentionHTTPPermission struct { + PathExact string `json:"pathExact,omitempty"` + PathPrefix string `json:"pathPrefix,omitempty"` + PathRegex string `json:"pathRegex,omitempty"` + + Header IntentionHTTPHeaderPermissions `json:"header,omitempty"` + + Methods []string `json:"methods,omitempty"` +} + +type IntentionHTTPHeaderPermission struct { + Name string `json:"name,omitempty"` + Present bool `json:"present,omitempty"` + Exact string `json:"exact,omitempty"` + Prefix string `json:"prefix,omitempty"` + Suffix string `json:"suffix,omitempty"` + Regex string `json:"regex,omitempty"` + Invert bool `json:"invert,omitempty"` +} + +// IntentionAction is the action that the intention represents. This +// can be "allow" or "deny" to allowlist or denylist intentions. +type IntentionAction string + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// ServiceIntentions is the Schema for the serviceintentions API +type ServiceIntentions struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ServiceIntentionsSpec `json:"spec,omitempty"` + Status `json:"status,omitempty"` +} + +func (in *ServiceIntentions) ConsulMirroringNS() string { + return in.Spec.Destination.Namespace +} + +func (in *ServiceIntentions) GetObjectMeta() metav1.ObjectMeta { + return in.ObjectMeta +} + +func (in *ServiceIntentions) AddFinalizer(f string) { + in.ObjectMeta.Finalizers = append(in.Finalizers(), f) +} + +func (in *ServiceIntentions) RemoveFinalizer(f string) { + var newFinalizers []string + for _, oldF := range in.Finalizers() { + if oldF != f { + newFinalizers = append(newFinalizers, oldF) + } + } + in.ObjectMeta.Finalizers = newFinalizers +} + +func (in *ServiceIntentions) Finalizers() []string { + return in.ObjectMeta.Finalizers +} + +func (in *ServiceIntentions) ConsulKind() string { + return capi.ServiceIntentions +} + +func (in *ServiceIntentions) KubeKind() string { + return common.ServiceIntentions +} + +func (in *ServiceIntentions) ConsulName() string { + return in.Spec.Destination.Name +} + +func (in *ServiceIntentions) KubernetesName() string { + return in.ObjectMeta.Name +} + +func (in *ServiceIntentions) SetSyncedCondition(status corev1.ConditionStatus, reason, message string) { + in.Status.Conditions = Conditions{ + { + Type: ConditionSynced, + Status: status, + LastTransitionTime: metav1.Now(), + Reason: reason, + Message: message, + }, + } +} + +func (in *ServiceIntentions) 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 *ServiceIntentions) SyncedConditionStatus() corev1.ConditionStatus { + condition := in.Status.GetCondition(ConditionSynced) + if condition == nil { + return corev1.ConditionUnknown + } + return condition.Status +} + +func (in *ServiceIntentions) ToConsul(datacenter string) api.ConfigEntry { + return &capi.ServiceIntentionsConfigEntry{ + Kind: in.ConsulKind(), + Name: in.Spec.Destination.Name, + Sources: in.Spec.Sources.toConsul(), + Meta: meta(datacenter), + } +} + +func (in SourceIntentions) toConsul() []*capi.SourceIntention { + var consulSourceIntentions []*capi.SourceIntention + for _, intention := range in { + consulSourceIntentions = append(consulSourceIntentions, intention.toConsul()) + } + return consulSourceIntentions +} + +func (in *ServiceIntentions) ConsulGlobalResource() bool { + return false +} + +func (in *SourceIntention) toConsul() *capi.SourceIntention { + if in == nil { + return nil + } + return &capi.SourceIntention{ + Name: in.Name, + Namespace: in.Namespace, + Action: in.Action.toConsul(), + Permissions: in.Permissions.toConsul(), + Description: in.Description, + } +} + +func (in IntentionAction) toConsul() capi.IntentionAction { + return capi.IntentionAction(in) +} + +func (in IntentionPermissions) toConsul() []*capi.IntentionPermission { + var consulIntentionPermissions []*capi.IntentionPermission + for _, permission := range in { + consulIntentionPermissions = append(consulIntentionPermissions, &capi.IntentionPermission{ + Action: permission.Action.toConsul(), + HTTP: permission.HTTP.ToConsul(), + }) + } + return consulIntentionPermissions +} + +func (in *IntentionHTTPPermission) ToConsul() *capi.IntentionHTTPPermission { + if in == nil { + return nil + } + return &capi.IntentionHTTPPermission{ + PathExact: in.PathExact, + PathPrefix: in.PathPrefix, + PathRegex: in.PathRegex, + Header: in.Header.toConsul(), + Methods: in.Methods, + } +} + +func (in IntentionHTTPHeaderPermissions) toConsul() []capi.IntentionHTTPHeaderPermission { + var headerPermissions []capi.IntentionHTTPHeaderPermission + for _, permission := range in { + headerPermissions = append(headerPermissions, capi.IntentionHTTPHeaderPermission{ + Name: permission.Name, + Present: permission.Present, + Exact: permission.Exact, + Prefix: permission.Prefix, + Suffix: permission.Suffix, + Regex: permission.Regex, + Invert: permission.Invert, + }) + } + + return headerPermissions +} + +func (in *ServiceIntentions) MatchesConsul(candidate api.ConfigEntry) bool { + configEntry, ok := candidate.(*capi.ServiceIntentionsConfigEntry) + 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.ServiceIntentionsConfigEntry{}, "Namespace", "Meta", "ModifyIndex", "CreateIndex"), + cmpopts.IgnoreFields(capi.SourceIntention{}, "LegacyID", "LegacyMeta", "LegacyCreateTime", "LegacyUpdateTime", "Precedence", "Type"), + cmpopts.IgnoreUnexported(), + cmpopts.EquateEmpty(), + ) +} + +func (in *ServiceIntentions) Validate() error { + var errs field.ErrorList + path := field.NewPath("spec") + for i, source := range in.Spec.Sources { + if len(source.Permissions) > 0 && source.Action != "" { + asJSON, _ := json.Marshal(source) + errs = append(errs, field.Invalid(path.Child("sources").Index(i), string(asJSON), `action and permissions are mutually exclusive and only one of them can be specified`)) + } else if len(source.Permissions) == 0 { + if err := source.Action.validate(path.Child("sources").Index(i)); err != nil { + errs = append(errs, err) + } + } else { + if err := source.Permissions.validate(path.Child("sources").Index(i)); err != nil { + errs = append(errs, err...) + } + } + } + if len(errs) > 0 { + return apierrors.NewInvalid( + schema.GroupKind{Group: ConsulHashicorpGroup, Kind: common.ServiceIntentions}, + in.KubernetesName(), errs) + } + return nil +} + +func (in IntentionPermissions) validate(path *field.Path) field.ErrorList { + var errs field.ErrorList + for i, permission := range in { + if err := permission.Action.validate(path.Child("permissions").Index(i)); err != nil { + errs = append(errs, err) + } + if permission.HTTP != nil { + if err := permission.HTTP.validate(path.Child("permissions").Index(i)); err != nil { + errs = append(errs, err...) + } + } + } + return errs +} + +func (in *IntentionHTTPPermission) validate(path *field.Path) field.ErrorList { + var errs field.ErrorList + if invalidPathPrefix(in.PathPrefix) { + errs = append(errs, field.Invalid(path.Child("pathPrefix"), in.PathPrefix, `must begin with a '/'`)) + } + if invalidPathPrefix(in.PathExact) { + errs = append(errs, field.Invalid(path.Child("pathExact"), in.PathExact, `must begin with a '/'`)) + } + return errs +} + +// Default sets zero value fields on this object to their defaults. +func (in *ServiceIntentions) Default() { + if in.Spec.Destination.Namespace == "" { + in.Spec.Destination.Namespace = in.Namespace + } + for _, source := range in.Spec.Sources { + if source.Namespace == "" { + source.Namespace = in.Namespace + } + } +} + +func (in IntentionAction) validate(path *field.Path) *field.Error { + actions := []string{"allow", "deny"} + if !sliceContains(actions, string(in)) { + return field.Invalid(path.Child("action"), in, notInSliceMessage(actions)) + } + return nil +} + +// +kubebuilder:object:root=true + +// ServiceIntentionsList contains a list of ServiceIntentions +type ServiceIntentionsList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ServiceIntentions `json:"items"` +} + +func init() { + SchemeBuilder.Register(&ServiceIntentions{}, &ServiceIntentionsList{}) +} diff --git a/api/v1alpha1/serviceintentions_types_test.go b/api/v1alpha1/serviceintentions_types_test.go new file mode 100644 index 0000000000..9d8ba32c84 --- /dev/null +++ b/api/v1alpha1/serviceintentions_types_test.go @@ -0,0 +1,857 @@ +package v1alpha1 + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "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 TestServiceIntentions_MatchesConsul(t *testing.T) { + cases := map[string]struct { + Ours ServiceIntentions + Theirs capi.ConfigEntry + Matches bool + }{ + "empty fields matches": { + Ours: ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceIntentionsSpec{}, + }, + Theirs: &capi.ServiceIntentionsConfigEntry{ + Name: "", + Kind: capi.ServiceIntentions, + CreateIndex: 1, + ModifyIndex: 2, + Meta: map[string]string{ + common.SourceKey: common.SourceValue, + common.DatacenterKey: "datacenter", + }, + }, + Matches: true, + }, + "all fields set matches": { + Ours: ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "svc-name", + Namespace: "test", + }, + Sources: []*SourceIntention{ + { + Name: "svc1", + Namespace: "test", + Action: "allow", + Description: "allow access from svc1", + }, + { + Name: "*", + Namespace: "not-test", + Action: "deny", + Description: "disallow access from namespace not-test", + }, + { + Name: "svc-2", + Namespace: "bar", + Permissions: IntentionPermissions{ + { + Action: "allow", + HTTP: &IntentionHTTPPermission{ + PathExact: "/foo", + PathPrefix: "/bar", + PathRegex: "/baz", + Header: IntentionHTTPHeaderPermissions{ + { + Name: "header", + Present: true, + Exact: "exact", + Prefix: "prefix", + Suffix: "suffix", + Regex: "regex", + Invert: true, + }, + }, + Methods: []string{ + "GET", + "PUT", + }, + }, + }, + }, + Description: "an L7 config", + }, + }, + }, + }, + Theirs: &capi.ServiceIntentionsConfigEntry{ + Kind: capi.ServiceIntentions, + Name: "svc-name", + Namespace: "test", + Sources: []*capi.SourceIntention{ + { + Name: "svc1", + Namespace: "test", + Action: "allow", + Precedence: 0, + Description: "allow access from svc1", + }, + { + Name: "*", + Namespace: "not-test", + Action: "deny", + Precedence: 1, + Description: "disallow access from namespace not-test", + }, + { + Name: "svc-2", + Namespace: "bar", + Permissions: []*capi.IntentionPermission{ + { + Action: "allow", + HTTP: &capi.IntentionHTTPPermission{ + PathExact: "/foo", + PathPrefix: "/bar", + PathRegex: "/baz", + Header: []capi.IntentionHTTPHeaderPermission{ + { + Name: "header", + Present: true, + Exact: "exact", + Prefix: "prefix", + Suffix: "suffix", + Regex: "regex", + Invert: true, + }, + }, + Methods: []string{ + "GET", + "PUT", + }, + }, + }, + }, + Description: "an L7 config", + }, + }, + Meta: nil, + }, + Matches: true, + }, + "different types does not match": { + Ours: ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceIntentionsSpec{}, + }, + Theirs: &capi.ProxyConfigEntry{ + Name: "name", + Kind: capi.ServiceIntentions, + 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 TestServiceIntentions_ToConsul(t *testing.T) { + cases := map[string]struct { + Ours ServiceIntentions + Exp *capi.ServiceIntentionsConfigEntry + }{ + "empty fields": { + Ours: ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceIntentionsSpec{}, + }, + Exp: &capi.ServiceIntentionsConfigEntry{ + Name: "", + Kind: capi.ServiceIntentions, + Meta: map[string]string{ + common.SourceKey: common.SourceValue, + common.DatacenterKey: "datacenter", + }, + }, + }, + "every field set": { + Ours: ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "svc-name", + }, + Sources: []*SourceIntention{ + { + Name: "svc1", + Namespace: "test", + Action: "allow", + Description: "allow access from svc1", + }, + { + Name: "*", + Namespace: "not-test", + Action: "deny", + Description: "disallow access from namespace not-test", + }, + { + Name: "svc-2", + Namespace: "bar", + Permissions: IntentionPermissions{ + { + Action: "allow", + HTTP: &IntentionHTTPPermission{ + PathExact: "/foo", + PathPrefix: "/bar", + PathRegex: "/baz", + Header: IntentionHTTPHeaderPermissions{ + { + Name: "header", + Present: true, + Exact: "exact", + Prefix: "prefix", + Suffix: "suffix", + Regex: "regex", + Invert: true, + }, + }, + Methods: []string{ + "GET", + "PUT", + }, + }, + }, + }, + Description: "an L7 config", + }, + }, + }, + }, + Exp: &capi.ServiceIntentionsConfigEntry{ + Kind: capi.ServiceIntentions, + Name: "svc-name", + Sources: []*capi.SourceIntention{ + { + Name: "svc1", + Namespace: "test", + Action: "allow", + Description: "allow access from svc1", + }, + { + Name: "*", + Namespace: "not-test", + Action: "deny", + Description: "disallow access from namespace not-test", + }, + { + Name: "svc-2", + Namespace: "bar", + Permissions: []*capi.IntentionPermission{ + { + Action: "allow", + HTTP: &capi.IntentionHTTPPermission{ + PathExact: "/foo", + PathPrefix: "/bar", + PathRegex: "/baz", + Header: []capi.IntentionHTTPHeaderPermission{ + { + Name: "header", + Present: true, + Exact: "exact", + Prefix: "prefix", + Suffix: "suffix", + Regex: "regex", + Invert: true, + }, + }, + Methods: []string{ + "GET", + "PUT", + }, + }, + }, + }, + Description: "an L7 config", + }, + }, + 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") + serviceResolver, ok := act.(*capi.ServiceIntentionsConfigEntry) + require.True(t, ok, "could not cast") + require.Equal(t, c.Exp, serviceResolver) + }) + } +} + +func TestServiceIntentions_AddFinalizer(t *testing.T) { + serviceResolver := &ServiceIntentions{} + serviceResolver.AddFinalizer("finalizer") + require.Equal(t, []string{"finalizer"}, serviceResolver.ObjectMeta.Finalizers) +} + +func TestServiceIntentions_RemoveFinalizer(t *testing.T) { + serviceResolver := &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Finalizers: []string{"f1", "f2"}, + }, + } + serviceResolver.RemoveFinalizer("f1") + require.Equal(t, []string{"f2"}, serviceResolver.ObjectMeta.Finalizers) +} + +func TestServiceIntentions_SetSyncedCondition(t *testing.T) { + serviceResolver := &ServiceIntentions{} + serviceResolver.SetSyncedCondition(corev1.ConditionTrue, "reason", "message") + + require.Equal(t, corev1.ConditionTrue, serviceResolver.Status.Conditions[0].Status) + require.Equal(t, "reason", serviceResolver.Status.Conditions[0].Reason) + require.Equal(t, "message", serviceResolver.Status.Conditions[0].Message) + now := metav1.Now() + require.True(t, serviceResolver.Status.Conditions[0].LastTransitionTime.Before(&now)) +} + +func TestServiceIntentions_GetSyncedConditionStatus(t *testing.T) { + cases := []corev1.ConditionStatus{ + corev1.ConditionUnknown, + corev1.ConditionFalse, + corev1.ConditionTrue, + } + for _, status := range cases { + t.Run(string(status), func(t *testing.T) { + serviceResolver := &ServiceIntentions{ + Status: Status{ + Conditions: []Condition{{ + Type: ConditionSynced, + Status: status, + }}, + }, + } + + require.Equal(t, status, serviceResolver.SyncedConditionStatus()) + }) + } +} + +func TestServiceIntentions_GetConditionWhenStatusNil(t *testing.T) { + require.Nil(t, (&ServiceIntentions{}).GetCondition(ConditionSynced)) +} + +func TestServiceIntentions_SyncedConditionStatusWhenStatusNil(t *testing.T) { + require.Equal(t, corev1.ConditionUnknown, (&ServiceIntentions{}).SyncedConditionStatus()) +} + +func TestServiceIntentions_SyncedConditionWhenStatusNil(t *testing.T) { + status, reason, message := (&ServiceIntentions{}).SyncedCondition() + require.Equal(t, corev1.ConditionUnknown, status) + require.Equal(t, "", reason) + require.Equal(t, "", message) +} + +func TestServiceIntentions_ConsulKind(t *testing.T) { + require.Equal(t, capi.ServiceIntentions, (&ServiceIntentions{}).ConsulKind()) +} + +func TestServiceIntentions_KubeKind(t *testing.T) { + require.Equal(t, "serviceintentions", (&ServiceIntentions{}).KubeKind()) +} + +func TestServiceIntentions_ConsulName(t *testing.T) { + require.Equal(t, "foo", (&ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "bar", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo", + Namespace: "baz", + }, + }, + }).ConsulName()) +} + +func TestServiceIntentions_KubernetesName(t *testing.T) { + require.Equal(t, "test", (&ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "bar", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo", + Namespace: "baz", + }, + }, + }).KubernetesName()) +} + +func TestServiceIntentions_ConsulNamespace(t *testing.T) { + require.Equal(t, "baz", (&ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "bar", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo", + Namespace: "baz", + }, + }, + }).ConsulMirroringNS()) +} + +func TestServiceIntentions_ConsulGlobalResource(t *testing.T) { + require.False(t, (&ServiceIntentions{}).ConsulGlobalResource()) +} + +func TestServiceIntentions_ConsulNamespaceWithWildcard(t *testing.T) { + require.Equal(t, common.WildcardNamespace, (&ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "bar", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo", + Namespace: "*", + }, + }, + }).ConsulMirroringNS()) +} + +func TestServiceIntentions_ObjectMeta(t *testing.T) { + meta := metav1.ObjectMeta{ + Name: "name", + Namespace: "namespace", + } + serviceResolver := &ServiceIntentions{ + ObjectMeta: meta, + } + require.Equal(t, meta, serviceResolver.GetObjectMeta()) +} + +func TestServiceIntentions_Default(t *testing.T) { + cases := map[string]struct { + input *ServiceIntentions + output *ServiceIntentions + }{ + "destination.namespace blank, meta.namespace default": { + input: &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "default", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "bar", + }, + }, + }, + output: &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "default", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "bar", + Namespace: "default", + }, + }, + }, + }, + "destination.namespace blank, meta.namespace foobar": { + input: &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "foobar", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "bar", + }, + }, + }, + output: &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "foobar", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "bar", + Namespace: "foobar", + }, + }, + }, + }, + "sources.namespace blank, meta.namespace default": { + input: &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "default", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "bar", + Namespace: "foo", + }, + Sources: SourceIntentions{ + { + Name: "baz", + Action: "allow", + }, + }, + }, + }, + output: &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "default", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "bar", + Namespace: "foo", + }, + Sources: SourceIntentions{ + { + Name: "baz", + Action: "allow", + Namespace: "default", + }, + }, + }, + }, + }, + "sources.namespace blank, meta.namespace foobar": { + input: &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "foobar", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "bar", + Namespace: "foo", + }, + Sources: SourceIntentions{ + { + Name: "baz", + Action: "allow", + }, + }, + }, + }, + output: &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "foobar", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "bar", + Namespace: "foo", + }, + Sources: SourceIntentions{ + { + Name: "baz", + Action: "allow", + Namespace: "foobar", + }, + }, + }, + }, + }, + "only populated blank namespaces": { + input: &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "foobar", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "bar", + Namespace: "foo", + }, + Sources: SourceIntentions{ + { + Name: "baz", + Action: "allow", + }, + { + Name: "baz2", + Action: "allow", + Namespace: "another-namespace", + }, + }, + }, + }, + output: &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "foobar", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "bar", + Namespace: "foo", + }, + Sources: SourceIntentions{ + { + Name: "baz", + Action: "allow", + Namespace: "foobar", + }, + { + Name: "baz2", + Action: "allow", + Namespace: "another-namespace", + }, + }, + }, + }, + }, + } + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + testCase.input.Default() + require.True(t, cmp.Equal(testCase.input, testCase.output)) + }) + } +} + +func TestServiceIntentions_Validate(t *testing.T) { + cases := map[string]struct { + input *ServiceIntentions + expectedErrMsg string + }{ + "valid": { + &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "does-not-matter", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "dest-service", + Namespace: "namespace", + }, + Sources: SourceIntentions{ + { + Name: "web", + Namespace: "web", + Action: "allow", + }, + { + Name: "db", + Namespace: "db", + Action: "deny", + }, + { + Name: "bar", + Namespace: "bar", + Permissions: IntentionPermissions{ + { + Action: "allow", + HTTP: &IntentionHTTPPermission{ + PathExact: "/foo", + PathPrefix: "/bar", + PathRegex: "/baz", + Header: IntentionHTTPHeaderPermissions{ + { + Name: "header", + Present: true, + Exact: "exact", + Prefix: "prefix", + Suffix: "suffix", + Regex: "regex", + Invert: true, + }, + }, + Methods: []string{ + "GET", + "PUT", + }, + }, + }, + }, + Description: "an L7 config", + }, + }, + }, + }, + "", + }, + "invalid action": { + &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "does-not-matter", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "dest-service", + Namespace: "namespace", + }, + Sources: SourceIntentions{ + { + Name: "web", + Namespace: "web", + Action: "foo", + }, + }, + }, + }, + `serviceintentions.consul.hashicorp.com "does-not-matter" is invalid: spec.sources[0].action: Invalid value: "foo": must be one of "allow", "deny"`, + }, + "invalid permissions.http.pathPrefix": { + &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "does-not-matter", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "dest-service", + Namespace: "namespace", + }, + Sources: SourceIntentions{ + { + Name: "svc-2", + Namespace: "bar", + Permissions: IntentionPermissions{ + { + Action: "allow", + HTTP: &IntentionHTTPPermission{ + PathPrefix: "bar", + }, + }, + }, + }, + }, + }, + }, + `serviceintentions.consul.hashicorp.com "does-not-matter" is invalid: spec.sources[0].permissions[0].pathPrefix: Invalid value: "bar": must begin with a '/'`, + }, + "invalid permissions.http.pathExact": { + &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "does-not-matter", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "dest-service", + Namespace: "namespace", + }, + Sources: SourceIntentions{ + { + Name: "svc-2", + Namespace: "bar", + Permissions: IntentionPermissions{ + { + Action: "allow", + HTTP: &IntentionHTTPPermission{ + PathExact: "bar", + }, + }, + }, + }, + }, + }, + }, + `serviceintentions.consul.hashicorp.com "does-not-matter" is invalid: spec.sources[0].permissions[0].pathExact: Invalid value: "bar": must begin with a '/'`, + }, + "invalid permissions.action": { + &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "does-not-matter", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "dest-service", + Namespace: "namespace", + }, + Sources: SourceIntentions{ + { + Name: "svc-2", + Namespace: "bar", + Permissions: IntentionPermissions{ + { + Action: "foobar", + HTTP: &IntentionHTTPPermission{ + PathExact: "/bar", + }, + }, + }, + }, + }, + }, + }, + `serviceintentions.consul.hashicorp.com "does-not-matter" is invalid: spec.sources[0].permissions[0].action: Invalid value: "foobar": must be one of "allow", "deny"`, + }, + "both action and permissions specified": { + &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "does-not-matter", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "dest-service", + Namespace: "namespace", + }, + Sources: SourceIntentions{ + { + Name: "svc-2", + Namespace: "bar", + Action: "deny", + Permissions: IntentionPermissions{ + { + Action: "allow", + HTTP: &IntentionHTTPPermission{ + PathExact: "/bar", + }, + }, + }, + }, + }, + }, + }, + `serviceintentions.consul.hashicorp.com "does-not-matter" is invalid: spec.sources[0]: Invalid value: "{\"name\":\"svc-2\",\"namespace\":\"bar\",\"action\":\"deny\",\"permissions\":[{\"action\":\"allow\",\"http\":{\"pathExact\":\"/bar\"}}]}": action and permissions are mutually exclusive and only one of them can be specified`, + }, + } + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + err := testCase.input.Validate() + if testCase.expectedErrMsg != "" { + require.EqualError(t, err, testCase.expectedErrMsg) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/api/v1alpha1/serviceintentions_webhook.go b/api/v1alpha1/serviceintentions_webhook.go new file mode 100644 index 0000000000..70bc87635a --- /dev/null +++ b/api/v1alpha1/serviceintentions_webhook.go @@ -0,0 +1,94 @@ +package v1alpha1 + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/go-logr/logr" + capi "github.com/hashicorp/consul/api" + "k8s.io/api/admission/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// +kubebuilder:object:generate=false + +type ServiceIntentionsWebhook struct { + client.Client + ConsulClient *capi.Client + Logger logr.Logger + decoder *admission.Decoder + EnableConsulNamespaces bool + EnableNSMirroring bool +} + +// 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-serviceintentions,mutating=true,failurePolicy=fail,groups=consul.hashicorp.com,resources=serviceintentions,versions=v1alpha1,name=mutate-serviceintentions.consul.hashicorp.com,webhookVersions=v1beta1,sideEffects=None + +func (v *ServiceIntentionsWebhook) Handle(ctx context.Context, req admission.Request) admission.Response { + var svcIntentions ServiceIntentions + var svcIntentionsList ServiceIntentionsList + err := v.decoder.Decode(req, &svcIntentions) + if err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + svcIntentions.Default() + + singleConsulDestNS := !(v.EnableConsulNamespaces && v.EnableNSMirroring) + if req.Operation == v1beta1.Create { + v.Logger.Info("validate create", "name", svcIntentions.KubernetesName()) + + if err := v.Client.List(ctx, &svcIntentionsList); err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + + for _, item := range svcIntentionsList.Items { + if singleConsulDestNS { + // If all config entries will be registered in the same Consul namespace, then spec.name + // must be unique for all entries so two custom resources don't configure the same Consul resource. + if item.Spec.Destination.Name == svcIntentions.Spec.Destination.Name { + return admission.Errored(http.StatusBadRequest, + fmt.Errorf("an existing ServiceIntentions resource has `spec.destination.name: %s`", svcIntentions.Spec.Destination.Name)) + } + // If namespace mirroring is enabled, each config entry will be registered in the Consul namespace + // set in spec.namespace. Thus we must check that there isn't already a config entry that sets the same spec.name and spec.namespace. + } else if item.Spec.Destination.Name == svcIntentions.Spec.Destination.Name && item.Spec.Destination.Namespace == svcIntentions.Spec.Destination.Namespace { + return admission.Errored(http.StatusBadRequest, + fmt.Errorf("an existing ServiceIntentions resource has `spec.destination.name: %s` and `spec.destination.namespace: %s`", svcIntentions.Spec.Destination.Name, svcIntentions.Spec.Destination.Namespace)) + } + } + } else if req.Operation == v1beta1.Update { + v.Logger.Info("validate update", "name", svcIntentions.KubernetesName()) + var prevIntention, newIntention ServiceIntentions + if err := v.decoder.DecodeRaw(*req.OldObject.DeepCopy(), &prevIntention); err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + if err := v.decoder.DecodeRaw(*req.Object.DeepCopy(), &newIntention); err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + + // validate that name and namespace of a resource cannot be updated so ensure no dangling intentions in Consul + if prevIntention.Spec.Destination.Name != newIntention.Spec.Destination.Name || prevIntention.Spec.Destination.Namespace != newIntention.Spec.Destination.Namespace { + return admission.Errored(http.StatusBadRequest, errors.New("spec.destination.name and spec.destination.namespace are immutable fields for ServiceIntentions")) + } + } + + if err := svcIntentions.Validate(); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + return admission.Allowed(fmt.Sprintf("valid %s request", svcIntentions.KubeKind())) +} + +func (v *ServiceIntentionsWebhook) InjectDecoder(d *admission.Decoder) error { + v.decoder = d + return nil +} diff --git a/api/v1alpha1/serviceintentions_webhook_test.go b/api/v1alpha1/serviceintentions_webhook_test.go new file mode 100644 index 0000000000..5eb4e8ba76 --- /dev/null +++ b/api/v1alpha1/serviceintentions_webhook_test.go @@ -0,0 +1,400 @@ +package v1alpha1 + +import ( + "context" + "encoding/json" + "testing" + + logrtest "github.com/go-logr/logr/testing" + "github.com/stretchr/testify/require" + "k8s.io/api/admission/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +func TestValidateServiceIntentions_Create(t *testing.T) { + otherNS := "other" + + cases := map[string]struct { + existingResources []runtime.Object + newResource *ServiceIntentions + expAllow bool + expErrMessage string + mirror bool + }{ + "no duplicates, valid": { + existingResources: nil, + newResource: &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-intention", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo", + Namespace: "bar", + }, + }, + }, + expAllow: true, + mirror: false, + }, + "invalid action": { + existingResources: nil, + newResource: &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-intention", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo", + Namespace: "bar", + }, + Sources: SourceIntentions{ + { + Name: "bar", + Namespace: "foo", + Action: "fail", + }, + }, + }, + }, + expAllow: false, + mirror: false, + expErrMessage: "serviceintentions.consul.hashicorp.com \"foo-intention\" is invalid: spec.sources[0].action: Invalid value: \"fail\": must be one of \"allow\", \"deny\"", + }, + "intention managing service exists": { + existingResources: []runtime.Object{&ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-intention", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo", + Namespace: "bar", + }, + }, + }}, + newResource: &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bar-intention", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo", + Namespace: "bar", + }, + }, + }, + expAllow: false, + mirror: true, + expErrMessage: "an existing ServiceIntentions resource has `spec.destination.name: foo` and `spec.destination.namespace: bar`", + }, + "intention managing service with same name but different namespace with mirroring": { + existingResources: []runtime.Object{&ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-intention", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo", + Namespace: "bar", + }, + }, + }}, + newResource: &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bar-intention", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo", + Namespace: "baz", + }, + }, + }, + expAllow: true, + mirror: true, + expErrMessage: "", + }, + "intention managing service shares name but different namespace": { + existingResources: []runtime.Object{&ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-intention", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo", + Namespace: "bar", + }, + }, + }}, + newResource: &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bar-intention", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo", + Namespace: "baz", + }, + }, + }, + expAllow: false, + mirror: false, + expErrMessage: "an existing ServiceIntentions resource has `spec.destination.name: foo`", + }, + "intention managing service shares name": { + existingResources: []runtime.Object{&ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-intention", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo", + }, + }, + }}, + newResource: &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bar-intention", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo", + }, + }, + }, + expAllow: false, + mirror: false, + expErrMessage: "an existing ServiceIntentions resource has `spec.destination.name: foo`", + }, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + marshalledRequestObject, err := json.Marshal(c.newResource) + require.NoError(t, err) + s := runtime.NewScheme() + s.AddKnownTypes(GroupVersion, &ServiceIntentions{}, &ServiceIntentionsList{}) + client := fake.NewFakeClientWithScheme(s, c.existingResources...) + decoder, err := admission.NewDecoder(s) + require.NoError(t, err) + + validator := &ServiceIntentionsWebhook{ + Client: client, + ConsulClient: nil, + Logger: logrtest.TestLogger{T: t}, + decoder: decoder, + EnableConsulNamespaces: true, + EnableNSMirroring: c.mirror, + } + response := validator.Handle(ctx, admission.Request{ + AdmissionRequest: v1beta1.AdmissionRequest{ + Name: c.newResource.KubernetesName(), + Namespace: otherNS, + Operation: v1beta1.Create, + Object: runtime.RawExtension{ + Raw: marshalledRequestObject, + }, + }, + }) + + require.Equal(t, c.expAllow, response.Allowed) + if c.expErrMessage != "" { + require.Equal(t, c.expErrMessage, response.AdmissionResponse.Result.Message) + } + }) + } +} + +func TestValidateServiceIntentions_Update(t *testing.T) { + otherNS := "other" + + cases := map[string]struct { + existingResources []runtime.Object + newResource *ServiceIntentions + expAllow bool + expErrMessage string + mirror bool + }{ + "valid update": { + existingResources: []runtime.Object{&ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-intention", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo", + Namespace: "bar", + }, + Sources: SourceIntentions{ + { + Name: "bar", + Namespace: "foo", + Action: "allow", + }, + { + Name: "bar2", + Namespace: "foo2", + Action: "deny", + }, + }, + }, + }}, + newResource: &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-intention", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo", + Namespace: "bar", + }, + Sources: SourceIntentions{ + { + Name: "bar", + Namespace: "foo", + Action: "allow", + }, + }, + }, + }, + expAllow: true, + mirror: false, + }, + "updating name": { + existingResources: []runtime.Object{&ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-intention", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo", + Namespace: "bar", + }, + Sources: SourceIntentions{ + { + Name: "bar", + Namespace: "foo", + Action: "allow", + }, + { + Name: "bar2", + Namespace: "foo2", + Action: "deny", + }, + }, + }, + }}, + newResource: &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-intention", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo-bar", + Namespace: "bar", + }, + Sources: SourceIntentions{ + { + Name: "bar", + Namespace: "foo", + Action: "allow", + }, + }, + }, + }, + expAllow: false, + mirror: false, + expErrMessage: "spec.destination.name and spec.destination.namespace are immutable fields for ServiceIntentions", + }, + "namespace update": { + existingResources: []runtime.Object{&ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-intention", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo", + Namespace: "bar", + }, + Sources: SourceIntentions{ + { + Name: "bar", + Namespace: "foo", + Action: "allow", + }, + { + Name: "bar2", + Namespace: "foo2", + Action: "deny", + }, + }, + }, + }}, + newResource: &ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-intention", + }, + Spec: ServiceIntentionsSpec{ + Destination: Destination{ + Name: "foo", + Namespace: "bar-foo", + }, + Sources: SourceIntentions{ + { + Name: "bar", + Namespace: "foo", + Action: "allow", + }, + }, + }, + }, + expAllow: false, + mirror: false, + expErrMessage: "spec.destination.name and spec.destination.namespace are immutable fields for ServiceIntentions", + }, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + marshalledRequestObject, err := json.Marshal(c.newResource) + marshalledOldRequestObject, err := json.Marshal(c.existingResources[0]) + require.NoError(t, err) + s := runtime.NewScheme() + s.AddKnownTypes(GroupVersion, &ServiceIntentions{}, &ServiceIntentionsList{}) + client := fake.NewFakeClientWithScheme(s, c.existingResources...) + decoder, err := admission.NewDecoder(s) + require.NoError(t, err) + + validator := &ServiceIntentionsWebhook{ + Client: client, + ConsulClient: nil, + Logger: logrtest.TestLogger{T: t}, + decoder: decoder, + EnableConsulNamespaces: true, + EnableNSMirroring: c.mirror, + } + response := validator.Handle(ctx, admission.Request{ + AdmissionRequest: v1beta1.AdmissionRequest{ + Name: c.newResource.KubernetesName(), + Namespace: otherNS, + Operation: v1beta1.Update, + Object: runtime.RawExtension{ + Raw: marshalledRequestObject, + }, + OldObject: runtime.RawExtension{ + Raw: marshalledOldRequestObject, + }, + }, + }) + + require.Equal(t, c.expAllow, response.Allowed) + if c.expErrMessage != "" { + require.Equal(t, c.expErrMessage, response.AdmissionResponse.Result.Message) + } + }) + } +} diff --git a/api/v1alpha1/serviceresolver_types.go b/api/v1alpha1/serviceresolver_types.go new file mode 100644 index 0000000000..786f510e70 --- /dev/null +++ b/api/v1alpha1/serviceresolver_types.go @@ -0,0 +1,475 @@ +package v1alpha1 + +import ( + "encoding/json" + "sort" + "time" + + "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 ServiceResolverKubeKind string = "serviceresolver" + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// ServiceResolver is the Schema for the serviceresolvers API +// +kubebuilder:printcolumn:name="Synced",type="string",JSONPath=".status.conditions[?(@.type==\"Synced\")].status",description="The sync status of the resource with Consul" +type ServiceResolver struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec ServiceResolverSpec `json:"spec,omitempty"` + Status `json:"status,omitempty"` +} + +func (in *ServiceResolver) ConsulKind() string { + return capi.ServiceResolver +} + +func (in *ServiceResolver) ConsulMirroringNS() string { + return in.Namespace +} + +func (in *ServiceResolver) KubeKind() string { + return ServiceResolverKubeKind +} + +func (in *ServiceResolver) GetObjectMeta() metav1.ObjectMeta { + return in.ObjectMeta +} + +func (in *ServiceResolver) KubernetesName() string { + return in.ObjectMeta.Name +} + +func (in *ServiceResolver) AddFinalizer(f string) { + in.ObjectMeta.Finalizers = append(in.Finalizers(), f) +} + +func (in *ServiceResolver) RemoveFinalizer(f string) { + var newFinalizers []string + for _, oldF := range in.Finalizers() { + if oldF != f { + newFinalizers = append(newFinalizers, oldF) + } + } + in.ObjectMeta.Finalizers = newFinalizers +} + +func (in *ServiceResolver) Finalizers() []string { + return in.ObjectMeta.Finalizers +} + +func (in *ServiceResolver) ConsulName() string { + return in.ObjectMeta.Name +} + +func (in *ServiceResolver) SetSyncedCondition(status corev1.ConditionStatus, reason string, message string) { + in.Status.Conditions = Conditions{ + { + Type: ConditionSynced, + Status: status, + LastTransitionTime: metav1.Now(), + Reason: reason, + Message: message, + }, + } +} + +func (in *ServiceResolver) SyncedCondition() (status corev1.ConditionStatus, reason string, message string) { + cond := in.Status.GetCondition(ConditionSynced) + if cond == nil { + return corev1.ConditionUnknown, "", "" + } + return cond.Status, cond.Reason, cond.Message +} + +func (in *ServiceResolver) SyncedConditionStatus() corev1.ConditionStatus { + condition := in.Status.GetCondition(ConditionSynced) + if condition == nil { + return corev1.ConditionUnknown + } + return condition.Status +} + +// ToConsul converts the entry into its Consul equivalent struct. +func (in *ServiceResolver) ToConsul(datacenter string) capi.ConfigEntry { + return &capi.ServiceResolverConfigEntry{ + Kind: in.ConsulKind(), + Name: in.ConsulName(), + DefaultSubset: in.Spec.DefaultSubset, + Subsets: in.Spec.Subsets.toConsul(), + Redirect: in.Spec.Redirect.toConsul(), + Failover: in.Spec.Failover.toConsul(), + ConnectTimeout: in.Spec.ConnectTimeout, + LoadBalancer: in.Spec.LoadBalancer.toConsul(), + Meta: meta(datacenter), + } +} + +func (in *ServiceResolver) MatchesConsul(candidate capi.ConfigEntry) bool { + configEntry, ok := candidate.(*capi.ServiceResolverConfigEntry) + 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.ServiceResolverConfigEntry{}, "Namespace", "Meta", "ModifyIndex", "CreateIndex"), cmpopts.IgnoreUnexported(), cmpopts.EquateEmpty()) +} + +func (in *ServiceResolver) ConsulGlobalResource() bool { + return false +} + +func (in *ServiceResolver) Validate() error { + var errs field.ErrorList + path := field.NewPath("spec") + + // Iterate through failover map keys in sorted order so tests are + // deterministic. + keys := make([]string, 0, len(in.Spec.Failover)) + for k := range in.Spec.Failover { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + f := in.Spec.Failover[k] + if err := f.validate(path.Child("failover").Key(k)); err != nil { + errs = append(errs, err) + } + } + + errs = append(errs, in.Spec.LoadBalancer.validate(path.Child("loadBalancer"))...) + + if len(errs) > 0 { + return apierrors.NewInvalid( + schema.GroupKind{Group: ConsulHashicorpGroup, Kind: ServiceResolverKubeKind}, + in.KubernetesName(), errs) + } + return nil +} + +func (in *ServiceResolverFailover) validate(path *field.Path) *field.Error { + if in.Service == "" && in.ServiceSubset == "" && in.Namespace == "" && len(in.Datacenters) == 0 { + // NOTE: We're passing "{}" here as our value because we know that the + // error is we have an empty object. + return field.Invalid(path, "{}", + "service, serviceSubset, namespace and datacenters cannot all be empty at once") + } + return nil +} + +// +kubebuilder:object:root=true + +// ServiceResolverList contains a list of ServiceResolver +type ServiceResolverList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ServiceResolver `json:"items"` +} + +// ServiceResolverSpec defines the desired state of ServiceResolver +type ServiceResolverSpec struct { + // DefaultSubset is the subset to use when no explicit subset is requested. + // If empty the unnamed subset is used. + DefaultSubset string `json:"defaultSubset,omitempty"` + // Subsets is map of subset name to subset definition for all usable named + // subsets of this service. The map key is the name of the subset and all + // names must be valid DNS subdomain elements. + // This may be empty, in which case only the unnamed default subset will + // be usable. + Subsets ServiceResolverSubsetMap `json:"subsets,omitempty"` + // Redirect when configured, all attempts to resolve the service this + // resolver defines will be substituted for the supplied redirect + // EXCEPT when the redirect has already been applied. + // When substituting the supplied redirect, all other fields besides + // Kind, Name, and Redirect will be ignored. + Redirect *ServiceResolverRedirect `json:"redirect,omitempty"` + // Failover controls when and how to reroute traffic to an alternate pool of + // service instances. + // The map is keyed by the service subset it applies to and the special + // string "*" is a wildcard that applies to any subset not otherwise + // specified here. + Failover ServiceResolverFailoverMap `json:"failover,omitempty"` + // ConnectTimeout is the timeout for establishing new network connections + // to this service. + ConnectTimeout time.Duration `json:"connectTimeout,omitempty"` + // LoadBalancer determines the load balancing policy and configuration for services + // issuing requests to this upstream service. + LoadBalancer *LoadBalancer `json:"loadBalancer,omitempty"` +} + +type ServiceResolverRedirect struct { + // Service is a service to resolve instead of the current service. + Service string `json:"service,omitempty"` + // ServiceSubset is a named subset of the given service to resolve instead + // of one defined as that service's DefaultSubset If empty the default + // subset is used. + ServiceSubset string `json:"serviceSubset,omitempty"` + // Namespace is the namespace to resolve the service from instead of the + // current one. + Namespace string `json:"namespace,omitempty"` + // Datacenter is the datacenter to resolve the service from instead of the + // current one. + Datacenter string `json:"datacenter,omitempty"` +} + +type ServiceResolverSubsetMap map[string]ServiceResolverSubset + +type ServiceResolverFailoverMap map[string]ServiceResolverFailover + +type ServiceResolverSubset struct { + // Filter is the filter expression to be used for selecting instances of the + // requested service. If empty all healthy instances are returned. This + // expression can filter on the same selectors as the Health API endpoint. + Filter string `json:"filter,omitempty"` + // OnlyPassing specifies the behavior of the resolver's health check + // interpretation. If this is set to false, instances with checks in the + // passing as well as the warning states will be considered healthy. If this + // is set to true, only instances with checks in the passing state will be + // considered healthy. + OnlyPassing bool `json:"onlyPassing,omitempty"` +} + +type ServiceResolverFailover struct { + // Service is the service to resolve instead of the default as the failover + // group of instances during failover. + Service string `json:"service,omitempty"` + // ServiceSubset is the named subset of the requested service to resolve as + // the failover group of instances. If empty the default subset for the + // requested service is used. + ServiceSubset string `json:"serviceSubset,omitempty"` + // Namespace is the namespace to resolve the requested service from to form + // the failover group of instances. If empty the current namespace is used. + Namespace string `json:"namespaces,omitempty"` + // Datacenters is a fixed list of datacenters to try during failover. + Datacenters []string `json:"datacenters,omitempty"` +} + +type LoadBalancer struct { + // Policy is the load balancing policy used to select a host. + Policy string `json:"policy,omitempty"` + + // RingHashConfig contains configuration for the "ringHash" policy type. + RingHashConfig *RingHashConfig `json:"ringHashConfig,omitempty"` + + // LeastRequestConfig contains configuration for the "leastRequest" policy type. + LeastRequestConfig *LeastRequestConfig `json:"leastRequestConfig,omitempty"` + + // HashPolicies is a list of hash policies to use for hashing load balancing algorithms. + // Hash policies are evaluated individually and combined such that identical lists + // result in the same hash. + // If no hash policies are present, or none are successfully evaluated, + // then a random backend host will be selected. + HashPolicies []HashPolicy `json:"hashPolicies,omitempty"` +} + +func (in *LoadBalancer) validate(path *field.Path) field.ErrorList { + if in == nil { + return nil + } + var errs field.ErrorList + for i, p := range in.HashPolicies { + if err := p.validate(path.Child("hashPolicies").Index(i)); err != nil { + errs = append(errs, err...) + } + } + return errs +} + +type RingHashConfig struct { + // MinimumRingSize determines the minimum number of entries in the hash ring. + MinimumRingSize uint64 `json:"minimumRingSize,omitempty"` + + // MaximumRingSize determines the maximum number of entries in the hash ring. + MaximumRingSize uint64 `json:"maximumRingSize,omitempty"` +} + +type LeastRequestConfig struct { + // ChoiceCount determines the number of random healthy hosts from which to select the one with the least requests. + ChoiceCount uint32 `json:"choiceCount,omitempty"` +} + +type HashPolicy struct { + // Field is the attribute type to hash on. + // Must be one of "header", "cookie", or "query_parameter". + // Cannot be specified along with sourceIP. + Field string `json:"field,omitempty"` + + // FieldValue is the value to hash. + // ie. header name, cookie name, URL query parameter name + // Cannot be specified along with sourceIP. + FieldValue string `json:"fieldValue,omitempty"` + + // CookieConfig contains configuration for the "cookie" hash policy type. + CookieConfig *CookieConfig `json:"cookieConfig,omitempty"` + + // SourceIP determines whether the hash should be of the source IP rather than of a field and field value. + // Cannot be specified along with field or fieldValue. + SourceIP bool `json:"sourceIP,omitempty"` + + // Terminal will short circuit the computation of the hash when multiple hash policies are present. + // If a hash is computed when a Terminal policy is evaluated, + // then that hash will be used and subsequent hash policies will be ignored. + Terminal bool `json:"terminal,omitempty"` +} + +func (in HashPolicy) validate(path *field.Path) field.ErrorList { + var errs field.ErrorList + validFields := []string{"header", "cookie", "query_parameter"} + if !sliceContains(validFields, in.Field) { + errs = append(errs, field.Invalid(path.Child("field"), in.Field, + notInSliceMessage(validFields))) + } + + if in.Field != "" && in.SourceIP { + asJSON, _ := json.Marshal(in) + errs = append(errs, field.Invalid(path, string(asJSON), + "cannot set both field and sourceIP")) + } + + if err := in.CookieConfig.validate(path.Child("cookieConfig")); err != nil { + errs = append(errs, err) + } + return errs +} + +type CookieConfig struct { + // Session determines whether to generate a session cookie with no expiration. + Session bool `json:"session,omitempty"` + + // TTL is the ttl for generated cookies. Cannot be specified for session cookies. + TTL time.Duration `json:"ttl,omitempty"` + + // Path is the path to set for the cookie. + Path string `json:"path,omitempty"` +} + +func (in *CookieConfig) validate(path *field.Path) *field.Error { + if in == nil { + return nil + } + + if in.Session && in.TTL > 0 { + asJSON, _ := json.Marshal(in) + return field.Invalid(path, string(asJSON), "cannot set both session and ttl") + } + return nil +} + +func init() { + SchemeBuilder.Register(&ServiceResolver{}, &ServiceResolverList{}) +} + +func (in ServiceResolverSubsetMap) toConsul() map[string]capi.ServiceResolverSubset { + if in == nil { + return nil + } + m := make(map[string]capi.ServiceResolverSubset) + for k, v := range in { + m[k] = v.toConsul() + } + return m +} + +func (in ServiceResolverSubset) toConsul() capi.ServiceResolverSubset { + return capi.ServiceResolverSubset{ + Filter: in.Filter, + OnlyPassing: in.OnlyPassing, + } +} + +func (in *ServiceResolverRedirect) toConsul() *capi.ServiceResolverRedirect { + if in == nil { + return nil + } + return &capi.ServiceResolverRedirect{ + Service: in.Service, + ServiceSubset: in.ServiceSubset, + Namespace: in.Namespace, + Datacenter: in.Datacenter, + } +} + +func (in ServiceResolverFailoverMap) toConsul() map[string]capi.ServiceResolverFailover { + if in == nil { + return nil + } + m := make(map[string]capi.ServiceResolverFailover) + for k, v := range in { + m[k] = v.toConsul() + } + return m +} + +func (in ServiceResolverFailover) toConsul() capi.ServiceResolverFailover { + return capi.ServiceResolverFailover{ + Service: in.Service, + ServiceSubset: in.ServiceSubset, + Namespace: in.Namespace, + Datacenters: in.Datacenters, + } +} + +func (in *LoadBalancer) toConsul() *capi.LoadBalancer { + if in == nil { + return nil + } + var policies []capi.HashPolicy + for _, p := range in.HashPolicies { + policies = append(policies, p.toConsul()) + } + return &capi.LoadBalancer{ + Policy: in.Policy, + RingHashConfig: in.RingHashConfig.toConsul(), + LeastRequestConfig: in.LeastRequestConfig.toConsul(), + HashPolicies: policies, + } +} + +func (in *RingHashConfig) toConsul() *capi.RingHashConfig { + if in == nil { + return nil + } + return &capi.RingHashConfig{ + MinimumRingSize: in.MinimumRingSize, + MaximumRingSize: in.MaximumRingSize, + } +} + +func (in *LeastRequestConfig) toConsul() *capi.LeastRequestConfig { + if in == nil { + return nil + } + + return &capi.LeastRequestConfig{ + ChoiceCount: in.ChoiceCount, + } +} + +func (in HashPolicy) toConsul() capi.HashPolicy { + return capi.HashPolicy{ + Field: in.Field, + FieldValue: in.FieldValue, + CookieConfig: in.CookieConfig.toConsul(), + SourceIP: in.SourceIP, + Terminal: in.Terminal, + } +} + +func (in *CookieConfig) toConsul() *capi.CookieConfig { + if in == nil { + return nil + } + return &capi.CookieConfig{ + Session: in.Session, + TTL: in.TTL, + Path: in.Path, + } +} diff --git a/api/v1alpha1/serviceresolver_types_test.go b/api/v1alpha1/serviceresolver_types_test.go new file mode 100644 index 0000000000..c48aa3a324 --- /dev/null +++ b/api/v1alpha1/serviceresolver_types_test.go @@ -0,0 +1,555 @@ +package v1alpha1 + +import ( + "testing" + "time" + + "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 TestServiceResolver_MatchesConsul(t *testing.T) { + cases := map[string]struct { + Ours ServiceResolver + Theirs capi.ConfigEntry + Matches bool + }{ + "empty fields matches": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{}, + }, + Theirs: &capi.ServiceResolverConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + Namespace: "foobar", + CreateIndex: 1, + ModifyIndex: 2, + Meta: map[string]string{ + common.SourceKey: common.SourceValue, + common.DatacenterKey: "datacenter", + }, + }, + Matches: true, + }, + "all fields set matches": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{ + DefaultSubset: "default_subset", + Subsets: map[string]ServiceResolverSubset{ + "subset1": { + Filter: "filter1", + OnlyPassing: true, + }, + "subset2": { + Filter: "filter2", + OnlyPassing: false, + }, + }, + Redirect: &ServiceResolverRedirect{ + Service: "redirect", + ServiceSubset: "redirect_subset", + Namespace: "redirect_namespace", + Datacenter: "redirect_datacenter", + }, + Failover: map[string]ServiceResolverFailover{ + "failover1": { + Service: "failover1", + ServiceSubset: "failover_subset1", + Namespace: "failover_namespace1", + Datacenters: []string{"failover1_dc1", "failover1_dc2"}, + }, + "failover2": { + Service: "failover2", + ServiceSubset: "failover_subset2", + Namespace: "failover_namespace2", + Datacenters: []string{"failover2_dc1", "failover2_dc2"}, + }, + }, + ConnectTimeout: 1 * time.Second, + LoadBalancer: &LoadBalancer{ + Policy: "policy", + RingHashConfig: &RingHashConfig{ + MinimumRingSize: 1, + MaximumRingSize: 2, + }, + LeastRequestConfig: &LeastRequestConfig{ + ChoiceCount: 1, + }, + HashPolicies: []HashPolicy{ + { + Field: "field", + FieldValue: "value", + CookieConfig: &CookieConfig{ + Session: true, + TTL: 1, + Path: "path", + }, + SourceIP: true, + Terminal: true, + }, + }, + }, + }, + }, + Theirs: &capi.ServiceResolverConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + DefaultSubset: "default_subset", + Subsets: map[string]capi.ServiceResolverSubset{ + "subset1": { + Filter: "filter1", + OnlyPassing: true, + }, + "subset2": { + Filter: "filter2", + OnlyPassing: false, + }, + }, + Redirect: &capi.ServiceResolverRedirect{ + Service: "redirect", + ServiceSubset: "redirect_subset", + Namespace: "redirect_namespace", + Datacenter: "redirect_datacenter", + }, + Failover: map[string]capi.ServiceResolverFailover{ + "failover1": { + Service: "failover1", + ServiceSubset: "failover_subset1", + Namespace: "failover_namespace1", + Datacenters: []string{"failover1_dc1", "failover1_dc2"}, + }, + "failover2": { + Service: "failover2", + ServiceSubset: "failover_subset2", + Namespace: "failover_namespace2", + Datacenters: []string{"failover2_dc1", "failover2_dc2"}, + }, + }, + ConnectTimeout: 1 * time.Second, + LoadBalancer: &capi.LoadBalancer{ + Policy: "policy", + RingHashConfig: &capi.RingHashConfig{ + MinimumRingSize: 1, + MaximumRingSize: 2, + }, + LeastRequestConfig: &capi.LeastRequestConfig{ + ChoiceCount: 1, + }, + HashPolicies: []capi.HashPolicy{ + { + Field: "field", + FieldValue: "value", + CookieConfig: &capi.CookieConfig{ + Session: true, + TTL: 1, + Path: "path", + }, + SourceIP: true, + Terminal: true, + }, + }, + }, + }, + Matches: true, + }, + "different types does not match": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{}, + }, + Theirs: &capi.ProxyConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + 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 TestServiceResolver_ToConsul(t *testing.T) { + cases := map[string]struct { + Ours ServiceResolver + Exp *capi.ServiceResolverConfigEntry + }{ + "empty fields": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{}, + }, + Exp: &capi.ServiceResolverConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + Meta: map[string]string{ + common.SourceKey: common.SourceValue, + common.DatacenterKey: "datacenter", + }, + }, + }, + "every field set": { + Ours: ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceResolverSpec{ + DefaultSubset: "default_subset", + Subsets: map[string]ServiceResolverSubset{ + "subset1": { + Filter: "filter1", + OnlyPassing: true, + }, + "subset2": { + Filter: "filter2", + OnlyPassing: false, + }, + }, + Redirect: &ServiceResolverRedirect{ + Service: "redirect", + ServiceSubset: "redirect_subset", + Namespace: "redirect_namespace", + Datacenter: "redirect_datacenter", + }, + Failover: map[string]ServiceResolverFailover{ + "failover1": { + Service: "failover1", + ServiceSubset: "failover_subset1", + Namespace: "failover_namespace1", + Datacenters: []string{"failover1_dc1", "failover1_dc2"}, + }, + "failover2": { + Service: "failover2", + ServiceSubset: "failover_subset2", + Namespace: "failover_namespace2", + Datacenters: []string{"failover2_dc1", "failover2_dc2"}, + }, + }, + ConnectTimeout: 1 * time.Second, + LoadBalancer: &LoadBalancer{ + Policy: "policy", + RingHashConfig: &RingHashConfig{ + MinimumRingSize: 1, + MaximumRingSize: 2, + }, + LeastRequestConfig: &LeastRequestConfig{ + ChoiceCount: 1, + }, + HashPolicies: []HashPolicy{ + { + Field: "field", + FieldValue: "value", + CookieConfig: &CookieConfig{ + Session: true, + TTL: 1, + Path: "path", + }, + SourceIP: true, + Terminal: true, + }, + }, + }, + }, + }, + Exp: &capi.ServiceResolverConfigEntry{ + Name: "name", + Kind: capi.ServiceResolver, + DefaultSubset: "default_subset", + Subsets: map[string]capi.ServiceResolverSubset{ + "subset1": { + Filter: "filter1", + OnlyPassing: true, + }, + "subset2": { + Filter: "filter2", + OnlyPassing: false, + }, + }, + Redirect: &capi.ServiceResolverRedirect{ + Service: "redirect", + ServiceSubset: "redirect_subset", + Namespace: "redirect_namespace", + Datacenter: "redirect_datacenter", + }, + Failover: map[string]capi.ServiceResolverFailover{ + "failover1": { + Service: "failover1", + ServiceSubset: "failover_subset1", + Namespace: "failover_namespace1", + Datacenters: []string{"failover1_dc1", "failover1_dc2"}, + }, + "failover2": { + Service: "failover2", + ServiceSubset: "failover_subset2", + Namespace: "failover_namespace2", + Datacenters: []string{"failover2_dc1", "failover2_dc2"}, + }, + }, + ConnectTimeout: 1 * time.Second, + LoadBalancer: &capi.LoadBalancer{ + Policy: "policy", + RingHashConfig: &capi.RingHashConfig{ + MinimumRingSize: 1, + MaximumRingSize: 2, + }, + LeastRequestConfig: &capi.LeastRequestConfig{ + ChoiceCount: 1, + }, + HashPolicies: []capi.HashPolicy{ + { + Field: "field", + FieldValue: "value", + CookieConfig: &capi.CookieConfig{ + Session: true, + TTL: 1, + Path: "path", + }, + SourceIP: true, + Terminal: true, + }, + }, + }, + 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") + serviceResolver, ok := act.(*capi.ServiceResolverConfigEntry) + require.True(t, ok, "could not cast") + require.Equal(t, c.Exp, serviceResolver) + }) + } +} + +func TestServiceResolver_AddFinalizer(t *testing.T) { + serviceResolver := &ServiceResolver{} + serviceResolver.AddFinalizer("finalizer") + require.Equal(t, []string{"finalizer"}, serviceResolver.ObjectMeta.Finalizers) +} + +func TestServiceResolver_RemoveFinalizer(t *testing.T) { + serviceResolver := &ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Finalizers: []string{"f1", "f2"}, + }, + } + serviceResolver.RemoveFinalizer("f1") + require.Equal(t, []string{"f2"}, serviceResolver.ObjectMeta.Finalizers) +} + +func TestServiceResolver_SetSyncedCondition(t *testing.T) { + serviceResolver := &ServiceResolver{} + serviceResolver.SetSyncedCondition(corev1.ConditionTrue, "reason", "message") + + require.Equal(t, corev1.ConditionTrue, serviceResolver.Status.Conditions[0].Status) + require.Equal(t, "reason", serviceResolver.Status.Conditions[0].Reason) + require.Equal(t, "message", serviceResolver.Status.Conditions[0].Message) + now := metav1.Now() + require.True(t, serviceResolver.Status.Conditions[0].LastTransitionTime.Before(&now)) +} + +func TestServiceResolver_GetSyncedConditionStatus(t *testing.T) { + cases := []corev1.ConditionStatus{ + corev1.ConditionUnknown, + corev1.ConditionFalse, + corev1.ConditionTrue, + } + for _, status := range cases { + t.Run(string(status), func(t *testing.T) { + serviceResolver := &ServiceResolver{ + Status: Status{ + Conditions: []Condition{{ + Type: ConditionSynced, + Status: status, + }}, + }, + } + + require.Equal(t, status, serviceResolver.SyncedConditionStatus()) + }) + } +} + +func TestServiceResolver_GetConditionWhenStatusNil(t *testing.T) { + require.Nil(t, (&ServiceResolver{}).GetCondition(ConditionSynced)) +} + +func TestServiceResolver_SyncedConditionStatusWhenStatusNil(t *testing.T) { + require.Equal(t, corev1.ConditionUnknown, (&ServiceResolver{}).SyncedConditionStatus()) +} + +func TestServiceResolver_SyncedConditionWhenStatusNil(t *testing.T) { + status, reason, message := (&ServiceResolver{}).SyncedCondition() + require.Equal(t, corev1.ConditionUnknown, status) + require.Equal(t, "", reason) + require.Equal(t, "", message) +} + +func TestServiceResolver_ConsulKind(t *testing.T) { + require.Equal(t, capi.ServiceResolver, (&ServiceResolver{}).ConsulKind()) +} + +func TestServiceResolver_KubeKind(t *testing.T) { + require.Equal(t, "serviceresolver", (&ServiceResolver{}).KubeKind()) +} + +func TestServiceResolver_ConsulName(t *testing.T) { + require.Equal(t, "foo", (&ServiceResolver{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}).ConsulName()) +} + +func TestServiceResolver_KubernetesName(t *testing.T) { + require.Equal(t, "foo", (&ServiceResolver{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}).KubernetesName()) +} + +func TestServiceResolver_ConsulNamespace(t *testing.T) { + require.Equal(t, "bar", (&ServiceResolver{ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "bar"}}).ConsulMirroringNS()) +} + +func TestServiceResolver_ConsulGlobalResource(t *testing.T) { + require.False(t, (&ServiceResolver{}).ConsulGlobalResource()) +} + +func TestServiceResolver_ObjectMeta(t *testing.T) { + meta := metav1.ObjectMeta{ + Name: "name", + Namespace: "namespace", + } + serviceResolver := &ServiceResolver{ + ObjectMeta: meta, + } + require.Equal(t, meta, serviceResolver.GetObjectMeta()) +} + +func TestServiceResolver_Validate(t *testing.T) { + cases := map[string]struct { + input *ServiceResolver + expectedErrMsg string + }{ + "valid": { + input: &ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: ServiceResolverSpec{ + Redirect: &ServiceResolverRedirect{ + Service: "bar", + }, + }, + }, + expectedErrMsg: "", + }, + "failover service, servicesubset, namespace, datacenters empty": { + input: &ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: ServiceResolverSpec{ + Failover: map[string]ServiceResolverFailover{ + "failA": { + Service: "", + ServiceSubset: "", + Namespace: "", + Datacenters: nil, + }, + "failB": { + Service: "", + ServiceSubset: "", + Namespace: "", + Datacenters: nil, + }, + }, + }, + }, + expectedErrMsg: "serviceresolver.consul.hashicorp.com \"foo\" is invalid: [spec.failover[failA]: Invalid value: \"{}\": service, serviceSubset, namespace and datacenters cannot all be empty at once, spec.failover[failB]: Invalid value: \"{}\": service, serviceSubset, namespace and datacenters cannot all be empty at once]", + }, + "hashPolicy.field invalid": { + input: &ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: ServiceResolverSpec{ + LoadBalancer: &LoadBalancer{ + HashPolicies: []HashPolicy{ + { + Field: "invalid", + }, + }, + }, + }, + }, + expectedErrMsg: `serviceresolver.consul.hashicorp.com "foo" is invalid: spec.loadBalancer.hashPolicies[0].field: Invalid value: "invalid": must be one of "header", "cookie", "query_parameter"`, + }, + "hashPolicy sourceIP and field set": { + input: &ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: ServiceResolverSpec{ + LoadBalancer: &LoadBalancer{ + HashPolicies: []HashPolicy{ + { + Field: "header", + SourceIP: true, + }, + }, + }, + }, + }, + expectedErrMsg: `serviceresolver.consul.hashicorp.com "foo" is invalid: spec.loadBalancer.hashPolicies[0]: Invalid value: "{\"field\":\"header\",\"sourceIP\":true}": cannot set both field and sourceIP`, + }, + "cookieConfig session and ttl set": { + input: &ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: ServiceResolverSpec{ + LoadBalancer: &LoadBalancer{ + HashPolicies: []HashPolicy{ + { + Field: "cookie", + CookieConfig: &CookieConfig{ + Session: true, + TTL: 100, + }, + }, + }, + }, + }, + }, + expectedErrMsg: `serviceresolver.consul.hashicorp.com "foo" is invalid: spec.loadBalancer.hashPolicies[0].cookieConfig: Invalid value: "{\"session\":true,\"ttl\":100}": cannot set both session and ttl`, + }, + } + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + err := testCase.input.Validate() + if testCase.expectedErrMsg != "" { + require.EqualError(t, err, testCase.expectedErrMsg) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/api/v1alpha1/serviceresolver_webhook.go b/api/v1alpha1/serviceresolver_webhook.go new file mode 100644 index 0000000000..7b80e6af09 --- /dev/null +++ b/api/v1alpha1/serviceresolver_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 ServiceResolverWebhook 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-serviceresolver,mutating=true,failurePolicy=fail,groups=consul.hashicorp.com,resources=serviceresolvers,versions=v1alpha1,name=mutate-serviceresolver.consul.hashicorp.com,webhookVersions=v1beta1,sideEffects=None + +func (v *ServiceResolverWebhook) Handle(ctx context.Context, req admission.Request) admission.Response { + var svcResolver ServiceResolver + err := v.decoder.Decode(req, &svcResolver) + if err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + return common.ValidateConfigEntry(ctx, + req, + v.Logger, + v, + &svcResolver, + v.EnableConsulNamespaces, + v.EnableNSMirroring) +} + +func (v *ServiceResolverWebhook) List(ctx context.Context) ([]common.ConfigEntryResource, error) { + var svcResolverList ServiceResolverList + if err := v.Client.List(ctx, &svcResolverList); err != nil { + return nil, err + } + var entries []common.ConfigEntryResource + for _, item := range svcResolverList.Items { + entries = append(entries, common.ConfigEntryResource(&item)) + } + return entries, nil +} + +func (v *ServiceResolverWebhook) InjectDecoder(d *admission.Decoder) error { + v.decoder = d + return nil +} diff --git a/api/v1alpha1/servicerouter_types.go b/api/v1alpha1/servicerouter_types.go new file mode 100644 index 0000000000..fd96882271 --- /dev/null +++ b/api/v1alpha1/servicerouter_types.go @@ -0,0 +1,429 @@ +package v1alpha1 + +import ( + "encoding/json" + "time" + + "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 ( + ServiceRouterKubeKind string = "servicerouter" +) + +// ServiceRouterSpec defines the desired state of ServiceRouter +type ServiceRouterSpec struct { + // Routes are the list of routes to consider when processing L7 requests. + // The first route to match in the list is terminal and stops further + // evaluation. Traffic that fails to match any of the provided routes will + // be routed to the default service. + Routes []ServiceRoute `json:"routes,omitempty"` +} + +type ServiceRoute struct { + // Match is a set of criteria that can match incoming L7 requests. + // If empty or omitted it acts as a catch-all. + Match *ServiceRouteMatch `json:"match,omitempty"` + // Destination controls how to proxy the matching request(s) to a service. + Destination *ServiceRouteDestination `json:"destination,omitempty"` +} + +func (in ServiceRoute) toConsul() capi.ServiceRoute { + return capi.ServiceRoute{ + Match: in.Match.toConsul(), + Destination: in.Destination.toConsul(), + } +} + +func (in ServiceRoute) validate(path *field.Path) field.ErrorList { + var errs field.ErrorList + if in.Destination != nil && in.Destination.PrefixRewrite != "" { + if in.Match == nil || in.Match.HTTP == nil || (in.Match.HTTP.PathPrefix == "" && in.Match.HTTP.PathExact == "") { + asJSON, _ := json.Marshal(in) + errs = append(errs, field.Invalid(path, string(asJSON), "destination.prefixRewrite requires that either match.http.pathPrefix or match.http.pathExact be configured on this route")) + } + } + if err := in.Match.validate(path.Child("match")); err != nil { + errs = append(err, err...) + } + + return errs +} + +type ServiceRouteMatch struct { + // HTTP is a set of http-specific match criteria. + HTTP *ServiceRouteHTTPMatch `json:"http,omitempty"` +} + +func (in *ServiceRouteMatch) toConsul() *capi.ServiceRouteMatch { + if in == nil { + return nil + } + return &capi.ServiceRouteMatch{ + HTTP: in.HTTP.toConsul(), + } +} + +func (in *ServiceRouteMatch) validate(path *field.Path) field.ErrorList { + if in == nil { + return nil + } + return in.HTTP.validate(path.Child("http")) +} + +type ServiceRouteHTTPMatch struct { + // PathExact is an exact path to match on the HTTP request path. + PathExact string `json:"pathExact,omitempty"` + // PathPrefix is a path prefix to match on the HTTP request path. + PathPrefix string `json:"pathPrefix,omitempty"` + // PathRegex is a regular expression to match on the HTTP request path. + PathRegex string `json:"pathRegex,omitempty"` + + // Header is a set of criteria that can match on HTTP request headers. + // If more than one is configured all must match for the overall match to apply. + Header []ServiceRouteHTTPMatchHeader `json:"header,omitempty"` + // QueryParam is a set of criteria that can match on HTTP query parameters. + // If more than one is configured all must match for the overall match to apply. + QueryParam []ServiceRouteHTTPMatchQueryParam `json:"queryParam,omitempty"` + // Methods is a list of HTTP methods for which this match applies. + // If unspecified all http methods are matched. + Methods []string `json:"methods,omitempty"` +} + +func (in *ServiceRouteHTTPMatch) toConsul() *capi.ServiceRouteHTTPMatch { + if in == nil { + return nil + } + var header []capi.ServiceRouteHTTPMatchHeader + for _, h := range in.Header { + header = append(header, h.toConsul()) + } + var query []capi.ServiceRouteHTTPMatchQueryParam + for _, q := range in.QueryParam { + query = append(query, q.toConsul()) + } + return &capi.ServiceRouteHTTPMatch{ + PathExact: in.PathExact, + PathPrefix: in.PathPrefix, + PathRegex: in.PathRegex, + Header: header, + QueryParam: query, + Methods: in.Methods, + } +} + +func (in *ServiceRouteHTTPMatch) validate(path *field.Path) field.ErrorList { + var errs field.ErrorList + if in == nil { + return nil + } + if numNonZeroValue(in.PathExact, in.PathPrefix, in.PathRegex) > 1 { + asJSON, _ := json.Marshal(in) + errs = append(errs, field.Invalid(path, string(asJSON), "at most only one of pathExact, pathPrefix, or pathRegex may be configured")) + } + if invalidPathPrefix(in.PathExact) { + errs = append(errs, field.Invalid(path.Child("pathExact"), in.PathExact, "must begin with a '/'")) + } + if invalidPathPrefix(in.PathPrefix) { + errs = append(errs, field.Invalid(path.Child("pathPrefix"), in.PathPrefix, "must begin with a '/'")) + } + + for i, h := range in.Header { + if err := h.validate(path.Child("header").Index(i)); err != nil { + errs = append(errs, err) + } + } + + for i, q := range in.QueryParam { + if err := q.validate(path.Child("queryParam").Index(i)); err != nil { + errs = append(errs, err) + } + } + return errs +} + +type ServiceRouteHTTPMatchHeader struct { + // Name is the name of the header to match. + Name string `json:"name"` + // Present will match if the header with the given name is present with any value. + Present bool `json:"present,omitempty"` + // Exact will match if the header with the given name is this value. + Exact string `json:"exact,omitempty"` + // Prefix will match if the header with the given name has this prefix. + Prefix string `json:"prefix,omitempty"` + // Suffix will match if the header with the given name has this suffix. + Suffix string `json:"suffix,omitempty"` + // Regex will match if the header with the given name matches this pattern. + Regex string `json:"regex,omitempty"` + // Invert inverts the logic of the match. + Invert bool `json:"invert,omitempty"` +} + +func (in ServiceRouteHTTPMatchHeader) toConsul() capi.ServiceRouteHTTPMatchHeader { + return capi.ServiceRouteHTTPMatchHeader{ + Name: in.Name, + Present: in.Present, + Exact: in.Exact, + Prefix: in.Prefix, + Suffix: in.Suffix, + Regex: in.Regex, + Invert: in.Invert, + } +} + +func (in *ServiceRouteHTTPMatchHeader) validate(path *field.Path) *field.Error { + if in == nil { + return nil + } + + if numNonZeroValue(in.Exact, in.Prefix, in.Suffix, in.Regex, in.Present) > 1 { + asJSON, _ := json.Marshal(in) + return field.Invalid(path, string(asJSON), "at most only one of exact, prefix, suffix, regex, or present may be configured") + } + return nil +} + +type ServiceRouteHTTPMatchQueryParam struct { + // Name is the name of the query parameter to match on. + Name string `json:"name"` + // Present will match if the query parameter with the given name is present + // with any value. + Present bool `json:"present,omitempty"` + // Exact will match if the query parameter with the given name is this value. + Exact string `json:"exact,omitempty"` + // Regex will match if the query parameter with the given name matches this pattern. + Regex string `json:"regex,omitempty"` +} + +func (in ServiceRouteHTTPMatchQueryParam) toConsul() capi.ServiceRouteHTTPMatchQueryParam { + return capi.ServiceRouteHTTPMatchQueryParam{ + Name: in.Name, + Present: in.Present, + Exact: in.Exact, + Regex: in.Regex, + } +} + +func (in *ServiceRouteHTTPMatchQueryParam) validate(path *field.Path) *field.Error { + if in == nil { + return nil + } + + if numNonZeroValue(in.Exact, in.Regex, in.Present) > 1 { + asJSON, _ := json.Marshal(in) + return field.Invalid(path, string(asJSON), "at most only one of exact, regex, or present may be configured") + } + return nil +} + +type ServiceRouteDestination struct { + // Service is the service to resolve instead of the default service. + // If empty then the default service name is used. + Service string `json:"service,omitempty"` + // ServiceSubset is a named subset of the given service to resolve instead + // of the one defined as that service's DefaultSubset. + // If empty, the default subset is used. + ServiceSubset string `json:"serviceSubset,omitempty"` + // Namespace is the Consul namespace to resolve the service from instead of + // the current namespace. If empty the current namespace is assumed. + Namespace string `json:"namespace,omitempty"` + // PrefixRewrite defines how to rewrite the HTTP request path before proxying + // it to its final destination. + // This requires that either match.http.pathPrefix or match.http.pathExact + // be configured on this route. + PrefixRewrite string `json:"prefixRewrite,omitempty"` + // RequestTimeout is the total amount of time permitted for the entire + // downstream request (and retries) to be processed. + RequestTimeout time.Duration `json:"requestTimeout,omitempty"` + // NumRetries is the number of times to retry the request when a retryable result occurs + NumRetries uint32 `json:"numRetries,omitempty"` + // RetryOnConnectFailure allows for connection failure errors to trigger a retry. + RetryOnConnectFailure bool `json:"retryOnConnectFailure,omitempty"` + // RetryOnStatusCodes is a flat list of http response status codes that are eligible for retry. + RetryOnStatusCodes []uint32 `json:"retryOnStatusCodes,omitempty"` +} + +func (in *ServiceRouteDestination) toConsul() *capi.ServiceRouteDestination { + if in == nil { + return nil + } + return &capi.ServiceRouteDestination{ + Service: in.Service, + ServiceSubset: in.ServiceSubset, + Namespace: in.Namespace, + PrefixRewrite: in.PrefixRewrite, + RequestTimeout: in.RequestTimeout, + NumRetries: in.NumRetries, + RetryOnConnectFailure: in.RetryOnConnectFailure, + RetryOnStatusCodes: in.RetryOnStatusCodes, + } +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// ServiceRouter is the Schema for the servicerouters API +// +kubebuilder:printcolumn:name="Synced",type="string",JSONPath=".status.conditions[?(@.type==\"Synced\")].status",description="The sync status of the resource with Consul" +type ServiceRouter struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ServiceRouterSpec `json:"spec,omitempty"` + Status `json:"status,omitempty"` +} + +func (in *ServiceRouter) ConsulMirroringNS() string { + return in.Namespace +} + +func (in *ServiceRouter) GetObjectMeta() metav1.ObjectMeta { + return in.ObjectMeta +} + +func (in *ServiceRouter) AddFinalizer(name string) { + in.ObjectMeta.Finalizers = append(in.Finalizers(), name) +} + +func (in *ServiceRouter) RemoveFinalizer(name string) { + var newFinalizers []string + for _, oldF := range in.Finalizers() { + if oldF != name { + newFinalizers = append(newFinalizers, oldF) + } + } + in.ObjectMeta.Finalizers = newFinalizers +} + +func (in *ServiceRouter) Finalizers() []string { + return in.ObjectMeta.Finalizers +} + +func (in *ServiceRouter) ConsulKind() string { + return capi.ServiceRouter +} + +func (in *ServiceRouter) KubeKind() string { + return ServiceRouterKubeKind +} + +func (in *ServiceRouter) ConsulName() string { + return in.ObjectMeta.Name +} + +func (in *ServiceRouter) KubernetesName() string { + return in.ObjectMeta.Name +} + +func (in *ServiceRouter) SetSyncedCondition(status corev1.ConditionStatus, reason, message string) { + in.Status.Conditions = Conditions{ + { + Type: ConditionSynced, + Status: status, + LastTransitionTime: metav1.Now(), + Reason: reason, + Message: message, + }, + } +} + +func (in *ServiceRouter) 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 *ServiceRouter) SyncedConditionStatus() corev1.ConditionStatus { + condition := in.Status.GetCondition(ConditionSynced) + if condition == nil { + return corev1.ConditionUnknown + } + return condition.Status +} + +func (in *ServiceRouter) ConsulGlobalResource() bool { + return false +} + +func (in *ServiceRouter) ToConsul(datacenter string) capi.ConfigEntry { + var routes []capi.ServiceRoute + for _, r := range in.Spec.Routes { + routes = append(routes, r.toConsul()) + } + return &capi.ServiceRouterConfigEntry{ + Kind: in.ConsulKind(), + Name: in.ConsulName(), + Routes: routes, + Meta: meta(datacenter), + } +} + +func (in *ServiceRouter) MatchesConsul(candidate capi.ConfigEntry) bool { + configEntry, ok := candidate.(*capi.ServiceRouterConfigEntry) + 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.ServiceRouterConfigEntry{}, "Namespace", "Meta", "ModifyIndex", "CreateIndex"), cmpopts.IgnoreUnexported(), cmpopts.EquateEmpty()) +} + +func (in *ServiceRouter) Validate() error { + var errs field.ErrorList + path := field.NewPath("spec") + for i, r := range in.Spec.Routes { + if err := r.validate(path.Child("routes").Index(i)); err != nil { + errs = append(errs, err...) + } + } + + if len(errs) > 0 { + return apierrors.NewInvalid( + schema.GroupKind{Group: ConsulHashicorpGroup, Kind: ServiceRouterKubeKind}, + in.KubernetesName(), errs) + } + return nil +} + +// +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 init() { + SchemeBuilder.Register(&ServiceRouter{}, &ServiceRouterList{}) +} + +// numNonZeroValue returns the number of elements that aren't set to their +// zero values. +func numNonZeroValue(elems ...interface{}) int { + var count int + for _, elem := range elems { + switch elem.(type) { + case string: + if elem != "" { + count++ + } + case bool: + if elem != false { + count++ + } + case int: + if elem != 0 { + count++ + } + } + } + return count +} diff --git a/api/v1alpha1/servicerouter_types_test.go b/api/v1alpha1/servicerouter_types_test.go new file mode 100644 index 0000000000..2f619e4070 --- /dev/null +++ b/api/v1alpha1/servicerouter_types_test.go @@ -0,0 +1,512 @@ +package v1alpha1 + +import ( + "testing" + "time" + + "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" +) + +// Test MatchesConsul. +func TestServiceRouter_MatchesConsul(t *testing.T) { + cases := map[string]struct { + Ours ServiceRouter + Theirs capi.ConfigEntry + Matches bool + }{ + "empty fields matches": { + Ours: ServiceRouter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceRouterSpec{}, + }, + Theirs: &capi.ServiceRouterConfigEntry{ + Kind: capi.ServiceRouter, + Name: "name", + Namespace: "namespace", + CreateIndex: 1, + ModifyIndex: 2, + Meta: map[string]string{ + common.SourceKey: common.SourceValue, + common.DatacenterKey: "datacenter", + }, + }, + Matches: true, + }, + "all fields set matches": { + Ours: ServiceRouter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceRouterSpec{ + Routes: []ServiceRoute{ + { + Match: &ServiceRouteMatch{ + HTTP: &ServiceRouteHTTPMatch{ + PathExact: "pathExact", + PathPrefix: "pathPrefix", + PathRegex: "pathRegex", + Header: []ServiceRouteHTTPMatchHeader{ + { + Name: "name", + Present: true, + Exact: "exact", + Prefix: "prefix", + Suffix: "suffix", + Regex: "regex", + Invert: true, + }, + }, + QueryParam: []ServiceRouteHTTPMatchQueryParam{ + { + Name: "name", + Present: true, + Exact: "exact", + Regex: "regex", + }, + }, + Methods: []string{"method1", "method2"}, + }, + }, + Destination: &ServiceRouteDestination{ + Service: "service", + ServiceSubset: "serviceSubset", + Namespace: "namespace", + PrefixRewrite: "prefixRewrite", + RequestTimeout: 1 * time.Second, + NumRetries: 1, + RetryOnConnectFailure: true, + RetryOnStatusCodes: []uint32{500, 400}, + }, + }, + }, + }, + }, + Theirs: &capi.ServiceRouterConfigEntry{ + Name: "name", + Kind: capi.ServiceRouter, + Routes: []capi.ServiceRoute{ + { + Match: &capi.ServiceRouteMatch{ + HTTP: &capi.ServiceRouteHTTPMatch{ + PathExact: "pathExact", + PathPrefix: "pathPrefix", + PathRegex: "pathRegex", + Header: []capi.ServiceRouteHTTPMatchHeader{ + { + Name: "name", + Present: true, + Exact: "exact", + Prefix: "prefix", + Suffix: "suffix", + Regex: "regex", + Invert: true, + }, + }, + QueryParam: []capi.ServiceRouteHTTPMatchQueryParam{ + { + Name: "name", + Present: true, + Exact: "exact", + Regex: "regex", + }, + }, + Methods: []string{"method1", "method2"}, + }, + }, + Destination: &capi.ServiceRouteDestination{ + Service: "service", + ServiceSubset: "serviceSubset", + Namespace: "namespace", + PrefixRewrite: "prefixRewrite", + RequestTimeout: 1 * time.Second, + NumRetries: 1, + RetryOnConnectFailure: true, + RetryOnStatusCodes: []uint32{500, 400}, + }, + }, + }, + }, + Matches: true, + }, + "mismatched type does not match": { + Ours: ServiceRouter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceRouterSpec{}, + }, + Theirs: &capi.ProxyConfigEntry{ + Kind: capi.ServiceRouter, + Name: "name", + Namespace: "namespace", + 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 TestServiceRouter_ToConsul(t *testing.T) { + cases := map[string]struct { + Ours ServiceRouter + Exp *capi.ServiceRouterConfigEntry + }{ + "empty fields": { + Ours: ServiceRouter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceRouterSpec{}, + }, + Exp: &capi.ServiceRouterConfigEntry{ + Name: "name", + Kind: capi.ServiceRouter, + Meta: map[string]string{ + common.SourceKey: common.SourceValue, + common.DatacenterKey: "datacenter", + }, + }, + }, + "every field set": { + Ours: ServiceRouter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceRouterSpec{ + Routes: []ServiceRoute{ + { + Match: &ServiceRouteMatch{ + HTTP: &ServiceRouteHTTPMatch{ + PathExact: "pathExact", + PathPrefix: "pathPrefix", + PathRegex: "pathRegex", + Header: []ServiceRouteHTTPMatchHeader{ + { + Name: "name", + Present: true, + Exact: "exact", + Prefix: "prefix", + Suffix: "suffix", + Regex: "regex", + Invert: true, + }, + }, + QueryParam: []ServiceRouteHTTPMatchQueryParam{ + { + Name: "name", + Present: true, + Exact: "exact", + Regex: "regex", + }, + }, + Methods: []string{"method1", "method2"}, + }, + }, + Destination: &ServiceRouteDestination{ + Service: "service", + ServiceSubset: "serviceSubset", + Namespace: "namespace", + PrefixRewrite: "prefixRewrite", + RequestTimeout: 1 * time.Second, + NumRetries: 1, + RetryOnConnectFailure: true, + RetryOnStatusCodes: []uint32{500, 400}, + }, + }, + }, + }, + }, + Exp: &capi.ServiceRouterConfigEntry{ + Name: "name", + Kind: capi.ServiceRouter, + Routes: []capi.ServiceRoute{ + { + Match: &capi.ServiceRouteMatch{ + HTTP: &capi.ServiceRouteHTTPMatch{ + PathExact: "pathExact", + PathPrefix: "pathPrefix", + PathRegex: "pathRegex", + Header: []capi.ServiceRouteHTTPMatchHeader{ + { + Name: "name", + Present: true, + Exact: "exact", + Prefix: "prefix", + Suffix: "suffix", + Regex: "regex", + Invert: true, + }, + }, + QueryParam: []capi.ServiceRouteHTTPMatchQueryParam{ + { + Name: "name", + Present: true, + Exact: "exact", + Regex: "regex", + }, + }, + Methods: []string{"method1", "method2"}, + }, + }, + Destination: &capi.ServiceRouteDestination{ + Service: "service", + ServiceSubset: "serviceSubset", + Namespace: "namespace", + PrefixRewrite: "prefixRewrite", + RequestTimeout: 1 * time.Second, + NumRetries: 1, + RetryOnConnectFailure: true, + RetryOnStatusCodes: []uint32{500, 400}, + }, + }, + }, + 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") + ServiceRouter, ok := act.(*capi.ServiceRouterConfigEntry) + require.True(t, ok, "could not cast") + require.Equal(t, c.Exp, ServiceRouter) + }) + } +} + +func TestServiceRouter_AddFinalizer(t *testing.T) { + ServiceRouter := &ServiceRouter{} + ServiceRouter.AddFinalizer("finalizer") + require.Equal(t, []string{"finalizer"}, ServiceRouter.ObjectMeta.Finalizers) +} + +func TestServiceRouter_RemoveFinalizer(t *testing.T) { + ServiceRouter := &ServiceRouter{ + ObjectMeta: metav1.ObjectMeta{ + Finalizers: []string{"f1", "f2"}, + }, + } + ServiceRouter.RemoveFinalizer("f1") + require.Equal(t, []string{"f2"}, ServiceRouter.ObjectMeta.Finalizers) +} + +func TestServiceRouter_SetSyncedCondition(t *testing.T) { + ServiceRouter := &ServiceRouter{} + ServiceRouter.SetSyncedCondition(corev1.ConditionTrue, "reason", "message") + + require.Equal(t, corev1.ConditionTrue, ServiceRouter.Status.Conditions[0].Status) + require.Equal(t, "reason", ServiceRouter.Status.Conditions[0].Reason) + require.Equal(t, "message", ServiceRouter.Status.Conditions[0].Message) + now := metav1.Now() + require.True(t, ServiceRouter.Status.Conditions[0].LastTransitionTime.Before(&now)) +} + +func TestServiceRouter_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) { + ServiceRouter := &ServiceRouter{ + Status: Status{ + Conditions: []Condition{{ + Type: ConditionSynced, + Status: status, + }}, + }, + } + + require.Equal(t, status, ServiceRouter.SyncedConditionStatus()) + }) + } +} + +func TestServiceRouter_GetConditionWhenStatusNil(t *testing.T) { + require.Nil(t, (&ServiceRouter{}).GetCondition(ConditionSynced)) +} + +func TestServiceRouter_SyncedConditionStatusWhenStatusNil(t *testing.T) { + require.Equal(t, corev1.ConditionUnknown, (&ServiceRouter{}).SyncedConditionStatus()) +} + +func TestServiceRouter_SyncedConditionWhenStatusNil(t *testing.T) { + status, reason, message := (&ServiceRouter{}).SyncedCondition() + require.Equal(t, corev1.ConditionUnknown, status) + require.Equal(t, "", reason) + require.Equal(t, "", message) +} + +func TestServiceRouter_ConsulKind(t *testing.T) { + require.Equal(t, capi.ServiceRouter, (&ServiceRouter{}).ConsulKind()) +} + +func TestServiceRouter_KubeKind(t *testing.T) { + require.Equal(t, "servicerouter", (&ServiceRouter{}).KubeKind()) +} + +func TestServiceRouter_ConsulName(t *testing.T) { + require.Equal(t, "foo", (&ServiceRouter{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}).ConsulName()) +} + +func TestServiceRouter_KubernetesName(t *testing.T) { + require.Equal(t, "foo", (&ServiceRouter{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}).KubernetesName()) +} + +func TestServiceRouter_ConsulNamespace(t *testing.T) { + require.Equal(t, "bar", (&ServiceRouter{ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "bar"}}).ConsulMirroringNS()) +} + +func TestServiceRouter_ConsulGlobalResource(t *testing.T) { + require.False(t, (&ServiceRouter{}).ConsulGlobalResource()) +} + +func TestServiceRouter_ObjectMeta(t *testing.T) { + meta := metav1.ObjectMeta{ + Name: "name", + Namespace: "namespace", + } + serviceRouter := &ServiceRouter{ + ObjectMeta: meta, + } + require.Equal(t, meta, serviceRouter.GetObjectMeta()) +} + +func TestServiceRouter_Validate(t *testing.T) { + cases := map[string]struct { + input *ServiceRouter + expectedErrMsg string + }{ + "valid": { + input: &ServiceRouter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: ServiceRouterSpec{ + Routes: []ServiceRoute{ + { + Match: &ServiceRouteMatch{ + HTTP: &ServiceRouteHTTPMatch{ + PathPrefix: "/admin", + }, + }, + }, + }, + }, + }, + }, + "http match queryParam": { + input: &ServiceRouter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: ServiceRouterSpec{ + Routes: []ServiceRoute{ + { + Match: &ServiceRouteMatch{ + HTTP: &ServiceRouteHTTPMatch{ + PathExact: "exact", + PathPrefix: "prefix", + PathRegex: "regex", + QueryParam: []ServiceRouteHTTPMatchQueryParam{ + { + Name: "name", + Present: true, + Exact: "exact", + Regex: "regex", + }, + }, + }, + }, + }, + }, + }, + }, + expectedErrMsg: `servicerouter.consul.hashicorp.com "foo" is invalid: [spec.routes[0].match.http: Invalid value: "{\"pathExact\":\"exact\",\"pathPrefix\":\"prefix\",\"pathRegex\":\"regex\",\"queryParam\":[{\"name\":\"name\",\"present\":true,\"exact\":\"exact\",\"regex\":\"regex\"}]}": at most only one of pathExact, pathPrefix, or pathRegex may be configured, spec.routes[0].match.http.pathExact: Invalid value: "exact": must begin with a '/', spec.routes[0].match.http.pathPrefix: Invalid value: "prefix": must begin with a '/', spec.routes[0].match.http.queryParam[0]: Invalid value: "{\"name\":\"name\",\"present\":true,\"exact\":\"exact\",\"regex\":\"regex\"}": at most only one of exact, regex, or present may be configured]`, + }, + "http match header": { + input: &ServiceRouter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: ServiceRouterSpec{ + Routes: []ServiceRoute{ + { + Match: &ServiceRouteMatch{ + HTTP: &ServiceRouteHTTPMatch{ + PathExact: "exact", + PathPrefix: "prefix", + PathRegex: "regex", + Header: []ServiceRouteHTTPMatchHeader{ + { + Name: "name", + Present: true, + Exact: "exact", + Prefix: "prefix", + Suffix: "suffix", + Regex: "regex", + }, + }, + }, + }, + }, + }, + }, + }, + expectedErrMsg: `servicerouter.consul.hashicorp.com "foo" is invalid: [spec.routes[0].match.http: Invalid value: "{\"pathExact\":\"exact\",\"pathPrefix\":\"prefix\",\"pathRegex\":\"regex\",\"header\":[{\"name\":\"name\",\"present\":true,\"exact\":\"exact\",\"prefix\":\"prefix\",\"suffix\":\"suffix\",\"regex\":\"regex\"}]}": at most only one of pathExact, pathPrefix, or pathRegex may be configured, spec.routes[0].match.http.pathExact: Invalid value: "exact": must begin with a '/', spec.routes[0].match.http.pathPrefix: Invalid value: "prefix": must begin with a '/', spec.routes[0].match.http.header[0]: Invalid value: "{\"name\":\"name\",\"present\":true,\"exact\":\"exact\",\"prefix\":\"prefix\",\"suffix\":\"suffix\",\"regex\":\"regex\"}": at most only one of exact, prefix, suffix, regex, or present may be configured]`, + }, + "destination and prefixRewrite": { + input: &ServiceRouter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: ServiceRouterSpec{ + Routes: []ServiceRoute{ + { + Destination: &ServiceRouteDestination{ + PrefixRewrite: "prefixRewrite", + }, + Match: &ServiceRouteMatch{ + HTTP: &ServiceRouteHTTPMatch{ + PathExact: "", + PathPrefix: "", + PathRegex: "", + Header: nil, + QueryParam: nil, + Methods: nil, + }, + }, + }, + }, + }, + }, + expectedErrMsg: `servicerouter.consul.hashicorp.com "foo" is invalid: spec.routes[0]: Invalid value: "{\"match\":{\"http\":{}},\"destination\":{\"prefixRewrite\":\"prefixRewrite\"}}": destination.prefixRewrite requires that either match.http.pathPrefix or match.http.pathExact be configured on this route`, + }, + } + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + err := testCase.input.Validate() + if testCase.expectedErrMsg != "" { + require.EqualError(t, err, testCase.expectedErrMsg) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/api/v1alpha1/servicerouter_webhook.go b/api/v1alpha1/servicerouter_webhook.go new file mode 100644 index 0000000000..4719b216f9 --- /dev/null +++ b/api/v1alpha1/servicerouter_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 ServiceRouterWebhook 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-servicerouter,mutating=true,failurePolicy=fail,groups=consul.hashicorp.com,resources=servicerouters,versions=v1alpha1,name=mutate-servicerouter.consul.hashicorp.com,webhookVersions=v1beta1,sideEffects=None + +func (v *ServiceRouterWebhook) Handle(ctx context.Context, req admission.Request) admission.Response { + var svcRouter ServiceRouter + err := v.decoder.Decode(req, &svcRouter) + if err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + return common.ValidateConfigEntry(ctx, + req, + v.Logger, + v, + &svcRouter, + v.EnableConsulNamespaces, + v.EnableNSMirroring) +} + +func (v *ServiceRouterWebhook) List(ctx context.Context) ([]common.ConfigEntryResource, error) { + var svcRouterList ServiceRouterList + if err := v.Client.List(ctx, &svcRouterList); err != nil { + return nil, err + } + var entries []common.ConfigEntryResource + for _, item := range svcRouterList.Items { + entries = append(entries, common.ConfigEntryResource(&item)) + } + return entries, nil +} + +func (v *ServiceRouterWebhook) InjectDecoder(d *admission.Decoder) error { + v.decoder = d + return nil +} diff --git a/api/v1alpha1/servicesplitter_types.go b/api/v1alpha1/servicesplitter_types.go new file mode 100644 index 0000000000..029e50286b --- /dev/null +++ b/api/v1alpha1/servicesplitter_types.go @@ -0,0 +1,218 @@ +package v1alpha1 + +import ( + "encoding/json" + "fmt" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/consul-k8s/api/common" + 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" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// ServiceSplitter is the Schema for the servicesplitters API +type ServiceSplitter struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ServiceSplitterSpec `json:"spec,omitempty"` + Status `json:"status,omitempty"` +} + +type ServiceSplits []ServiceSplit + +// ServiceSplitterSpec defines the desired state of ServiceSplitter +type ServiceSplitterSpec struct { + // Splits defines how much traffic to send to which set of service instances during a traffic split. + // The sum of weights across all splits must add up to 100. + Splits ServiceSplits `json:"splits,omitempty"` +} + +type ServiceSplit struct { + // Weight is a value between 0 and 100 reflecting what portion of traffic should be directed to this split. + // The smallest representable weight is 1/10000 or .01%. + Weight float32 `json:"weight,omitempty"` + // Service is the service to resolve instead of the default. + Service string `json:"service,omitempty"` + // ServiceSubset is a named subset of the given service to resolve instead of one defined + // as that service's DefaultSubset. If empty the default subset is used. + ServiceSubset string `json:"serviceSubset,omitempty"` + // The namespace to resolve the service from instead of the current namespace. + // If empty the current namespace is assumed. + Namespace string `json:"namespace,omitempty"` +} + +// +kubebuilder:object:root=true + +// ServiceSplitterList contains a list of ServiceSplitter +type ServiceSplitterList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ServiceSplitter `json:"items"` +} + +func (in *ServiceSplitter) GetObjectMeta() metav1.ObjectMeta { + return in.ObjectMeta +} + +func (in *ServiceSplitter) ConsulMirroringNS() string { + return in.Namespace +} + +func (in *ServiceSplitter) AddFinalizer(name string) { + in.ObjectMeta.Finalizers = append(in.Finalizers(), name) +} + +func (in *ServiceSplitter) RemoveFinalizer(name string) { + var newFinalizers []string + for _, oldF := range in.Finalizers() { + if oldF != name { + newFinalizers = append(newFinalizers, oldF) + } + } + in.ObjectMeta.Finalizers = newFinalizers +} + +func (in *ServiceSplitter) Finalizers() []string { + return in.ObjectMeta.Finalizers +} + +func (in *ServiceSplitter) ConsulKind() string { + return capi.ServiceSplitter +} + +func (in *ServiceSplitter) KubeKind() string { + return common.ServiceSplitter +} + +func (in *ServiceSplitter) ConsulName() string { + return in.ObjectMeta.Name +} + +func (in *ServiceSplitter) SetSyncedCondition(status corev1.ConditionStatus, reason, message string) { + in.Status.Conditions = Conditions{ + { + Type: ConditionSynced, + Status: status, + LastTransitionTime: metav1.Now(), + Reason: reason, + Message: message, + }, + } +} + +func (in *ServiceSplitter) 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 *ServiceSplitter) SyncedConditionStatus() corev1.ConditionStatus { + condition := in.Status.GetCondition(ConditionSynced) + if condition == nil { + return corev1.ConditionUnknown + } + return condition.Status +} + +func (in *ServiceSplitter) ToConsul(datacenter string) capi.ConfigEntry { + return &capi.ServiceSplitterConfigEntry{ + Kind: in.ConsulKind(), + Name: in.ConsulName(), + Splits: in.Spec.Splits.toConsul(), + Meta: meta(datacenter), + } +} + +func (in *ServiceSplitter) ConsulGlobalResource() bool { + return false +} + +func (in *ServiceSplitter) KubernetesName() string { + return in.ObjectMeta.Name +} + +func (in *ServiceSplitter) MatchesConsul(candidate capi.ConfigEntry) bool { + configEntry, ok := candidate.(*capi.ServiceSplitterConfigEntry) + 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.ServiceSplitterConfigEntry{}, "Namespace", "Meta", "ModifyIndex", "CreateIndex"), cmpopts.IgnoreUnexported(), cmpopts.EquateEmpty()) +} + +func (in *ServiceSplitter) Validate() error { + errs := in.Spec.Splits.validate(field.NewPath("spec").Child("splits")) + + if len(errs) > 0 { + return apierrors.NewInvalid( + schema.GroupKind{Group: ConsulHashicorpGroup, Kind: in.KubeKind()}, + in.KubernetesName(), errs) + } + return nil +} + +func (in ServiceSplits) validate(path *field.Path) field.ErrorList { + var errs field.ErrorList + + // The sum of weights across all splits must add up to 100. + sumOfWeights := float32(0) + for i, split := range in { + // First, validate each split. + if err := split.validate(path.Index(i).Child("weight")); err != nil { + errs = append(errs, err) + } + + // If valid, add its weight to sumOfWeights. + sumOfWeights += split.Weight + } + + if sumOfWeights != 100 { + asJSON, _ := json.Marshal(in) + errs = append(errs, field.Invalid(path, string(asJSON), + fmt.Sprintf("the sum of weights across all splits must add up to a 100 percent, but adds up to %f", sumOfWeights))) + } + + return errs +} + +func (in ServiceSplit) validate(path *field.Path) *field.Error { + // Validate that the weight value is between 0.01 and 100 but allow a weight to be 0. + if in.Weight != 0 && (in.Weight > 100 || in.Weight < 0.01) { + return field.Invalid(path, in.Weight, "weight must be a percentage between 0.01 and 100") + } + + return nil +} + +func (in ServiceSplits) toConsul() []capi.ServiceSplit { + var consulServiceSplits []capi.ServiceSplit + for _, split := range in { + consulServiceSplits = append(consulServiceSplits, split.toConsul()) + } + + return consulServiceSplits +} + +func (in ServiceSplit) toConsul() capi.ServiceSplit { + return capi.ServiceSplit{ + Weight: in.Weight, + Service: in.Service, + ServiceSubset: in.ServiceSubset, + Namespace: in.Namespace, + } +} + +func init() { + SchemeBuilder.Register(&ServiceSplitter{}, &ServiceSplitterList{}) +} diff --git a/api/v1alpha1/servicesplitter_types_test.go b/api/v1alpha1/servicesplitter_types_test.go new file mode 100644 index 0000000000..f464b2d142 --- /dev/null +++ b/api/v1alpha1/servicesplitter_types_test.go @@ -0,0 +1,350 @@ +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" +) + +// Test MatchesConsul. +func TestServiceSplitter_MatchesConsul(t *testing.T) { + cases := map[string]struct { + Ours ServiceSplitter + Theirs capi.ConfigEntry + Matches bool + }{ + "empty fields matches": { + Ours: ServiceSplitter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceSplitterSpec{}, + }, + Theirs: &capi.ServiceSplitterConfigEntry{ + Kind: capi.ServiceSplitter, + Name: "name", + Namespace: "namespace", + CreateIndex: 1, + ModifyIndex: 2, + Meta: map[string]string{ + common.SourceKey: common.SourceValue, + common.DatacenterKey: "datacenter", + }, + }, + Matches: true, + }, + "all fields set matches": { + Ours: ServiceSplitter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceSplitterSpec{ + Splits: []ServiceSplit{ + { + Weight: 100, + Service: "foo", + ServiceSubset: "bar", + Namespace: "baz", + }, + }, + }, + }, + Theirs: &capi.ServiceSplitterConfigEntry{ + Name: "name", + Kind: capi.ServiceSplitter, + Splits: []capi.ServiceSplit{ + { + Weight: 100, + Service: "foo", + ServiceSubset: "bar", + Namespace: "baz", + }, + }, + }, + Matches: true, + }, + "different types does not match": { + Ours: ServiceSplitter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceSplitterSpec{}, + }, + Theirs: &capi.ProxyConfigEntry{ + Kind: capi.ServiceSplitter, + Name: "name", + Namespace: "namespace", + 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 TestServiceSplitter_ToConsul(t *testing.T) { + cases := map[string]struct { + Ours ServiceSplitter + Exp *capi.ServiceSplitterConfigEntry + }{ + "empty fields": { + Ours: ServiceSplitter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceSplitterSpec{}, + }, + Exp: &capi.ServiceSplitterConfigEntry{ + Name: "name", + Kind: capi.ServiceSplitter, + Meta: map[string]string{ + common.SourceKey: common.SourceValue, + common.DatacenterKey: "datacenter", + }, + }, + }, + "every field set": { + Ours: ServiceSplitter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: ServiceSplitterSpec{ + Splits: []ServiceSplit{ + { + Weight: 100, + Service: "foo", + ServiceSubset: "bar", + Namespace: "baz", + }, + }, + }, + }, + Exp: &capi.ServiceSplitterConfigEntry{ + Name: "name", + Kind: capi.ServiceSplitter, + Splits: []capi.ServiceSplit{ + { + Weight: 100, + Service: "foo", + ServiceSubset: "bar", + Namespace: "baz", + }, + }, + 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") + ServiceSplitter, ok := act.(*capi.ServiceSplitterConfigEntry) + require.True(t, ok, "could not cast") + require.Equal(t, c.Exp, ServiceSplitter) + }) + } +} + +func TestServiceSplitter_AddFinalizer(t *testing.T) { + ServiceSplitter := &ServiceSplitter{} + ServiceSplitter.AddFinalizer("finalizer") + require.Equal(t, []string{"finalizer"}, ServiceSplitter.ObjectMeta.Finalizers) +} + +func TestServiceSplitter_RemoveFinalizer(t *testing.T) { + ServiceSplitter := &ServiceSplitter{ + ObjectMeta: metav1.ObjectMeta{ + Finalizers: []string{"f1", "f2"}, + }, + } + ServiceSplitter.RemoveFinalizer("f1") + require.Equal(t, []string{"f2"}, ServiceSplitter.ObjectMeta.Finalizers) +} + +func TestServiceSplitter_SetSyncedCondition(t *testing.T) { + ServiceSplitter := &ServiceSplitter{} + ServiceSplitter.SetSyncedCondition(corev1.ConditionTrue, "reason", "message") + + require.Equal(t, corev1.ConditionTrue, ServiceSplitter.Status.Conditions[0].Status) + require.Equal(t, "reason", ServiceSplitter.Status.Conditions[0].Reason) + require.Equal(t, "message", ServiceSplitter.Status.Conditions[0].Message) + now := metav1.Now() + require.True(t, ServiceSplitter.Status.Conditions[0].LastTransitionTime.Before(&now)) +} + +func TestServiceSplitter_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) { + ServiceSplitter := &ServiceSplitter{ + Status: Status{ + Conditions: []Condition{{ + Type: ConditionSynced, + Status: status, + }}, + }, + } + + require.Equal(t, status, ServiceSplitter.SyncedConditionStatus()) + }) + } +} + +func TestServiceSplitter_GetConditionWhenStatusNil(t *testing.T) { + require.Nil(t, (&ServiceSplitter{}).GetCondition(ConditionSynced)) +} + +func TestServiceSplitter_SyncedConditionStatusWhenStatusNil(t *testing.T) { + require.Equal(t, corev1.ConditionUnknown, (&ServiceSplitter{}).SyncedConditionStatus()) +} + +func TestServiceSplitter_SyncedConditionWhenStatusNil(t *testing.T) { + status, reason, message := (&ServiceSplitter{}).SyncedCondition() + require.Equal(t, corev1.ConditionUnknown, status) + require.Equal(t, "", reason) + require.Equal(t, "", message) +} + +func TestServiceSplitter_ConsulKind(t *testing.T) { + require.Equal(t, capi.ServiceSplitter, (&ServiceSplitter{}).ConsulKind()) +} + +func TestServiceSplitter_KubeKind(t *testing.T) { + require.Equal(t, "servicesplitter", (&ServiceSplitter{}).KubeKind()) +} + +func TestServiceSplitter_ConsulName(t *testing.T) { + require.Equal(t, "foo", (&ServiceSplitter{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}).ConsulName()) +} + +func TestServiceSplitter_KubernetesName(t *testing.T) { + require.Equal(t, "foo", (&ServiceSplitter{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}).KubernetesName()) +} + +func TestServiceSplitter_ConsulNamespace(t *testing.T) { + require.Equal(t, "bar", (&ServiceSplitter{ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "bar"}}).ConsulMirroringNS()) +} + +func TestServiceSplitter_ConsulGlobalResource(t *testing.T) { + require.False(t, (&ServiceSplitter{}).ConsulGlobalResource()) +} +func TestServiceSplitter_ObjectMeta(t *testing.T) { + meta := metav1.ObjectMeta{ + Name: "name", + Namespace: "namespace", + } + ServiceSplitter := &ServiceSplitter{ + ObjectMeta: meta, + } + require.Equal(t, meta, ServiceSplitter.GetObjectMeta()) +} + +func TestServiceSplitter_Validate(t *testing.T) { + cases := map[string]struct { + input *ServiceSplitter + expectedErrMsg string + }{ + "valid": { + input: &ServiceSplitter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: ServiceSplitterSpec{ + Splits: []ServiceSplit{ + { + Weight: 99.99, + }, + { + Weight: 0.01, + }, + }, + }, + }, + }, + + "valid - splits with 0 weight": { + input: &ServiceSplitter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: ServiceSplitterSpec{ + Splits: []ServiceSplit{ + { + Weight: 50.0, + }, + { + Weight: 50, + }, + { + Weight: 0.0, + }, + { + Weight: 0, + }, + }, + }, + }, + }, + "sum of weights must be 100": { + input: &ServiceSplitter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: ServiceSplitterSpec{ + Splits: []ServiceSplit{ + { + Weight: 90, + }, + { + Weight: 5, + }, + }, + }, + }, + expectedErrMsg: `servicesplitter.consul.hashicorp.com "foo" is invalid: spec.splits: Invalid value: "[{\"weight\":90},{\"weight\":5}]": the sum of weights across all splits must add up to a 100 percent, but adds up to 95.000000`, + }, + "weight must be between 0.01 and 100": { + input: &ServiceSplitter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: ServiceSplitterSpec{ + Splits: []ServiceSplit{ + { + Weight: 101, + }, + { + Weight: 0.001, + }, + }, + }, + }, + expectedErrMsg: `servicesplitter.consul.hashicorp.com "foo" is invalid: [spec.splits[0].weight: Invalid value: 101: weight must be a percentage between 0.01 and 100, spec.splits[1].weight: Invalid value: 0.001: weight must be a percentage between 0.01 and 100, spec.splits: Invalid value: "[{\"weight\":101},{\"weight\":0.001}]": the sum of weights across all splits must add up to a 100 percent, but adds up to 101.000999]`, + }, + } + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + err := testCase.input.Validate() + if testCase.expectedErrMsg != "" { + require.EqualError(t, err, testCase.expectedErrMsg) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/api/v1alpha1/servicesplitter_webhook.go b/api/v1alpha1/servicesplitter_webhook.go new file mode 100644 index 0000000000..7d31b0510e --- /dev/null +++ b/api/v1alpha1/servicesplitter_webhook.go @@ -0,0 +1,73 @@ +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 ServiceSplitterWebhook 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-servicesplitter,mutating=true,failurePolicy=fail,groups=consul.hashicorp.com,resources=servicesplitters,versions=v1alpha1,name=mutate-servicesplitter.consul.hashicorp.com,webhookVersions=v1beta1,sideEffects=None + +func (v *ServiceSplitterWebhook) Handle(ctx context.Context, req admission.Request) admission.Response { + var serviceSplitter ServiceSplitter + err := v.decoder.Decode(req, &serviceSplitter) + if err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + return common.ValidateConfigEntry(ctx, + req, + v.Logger, + v, + &serviceSplitter, + v.EnableConsulNamespaces, + v.EnableNSMirroring) +} + +func (v *ServiceSplitterWebhook) List(ctx context.Context) ([]common.ConfigEntryResource, error) { + var serviceSplitterList ServiceSplitterList + if err := v.Client.List(ctx, &serviceSplitterList); err != nil { + return nil, err + } + var entries []common.ConfigEntryResource + for _, item := range serviceSplitterList.Items { + entries = append(entries, common.ConfigEntryResource(&item)) + } + return entries, nil +} + +func (v *ServiceSplitterWebhook) InjectDecoder(d *admission.Decoder) error { + v.decoder = d + return nil +} diff --git a/api/v1alpha1/status.go b/api/v1alpha1/status.go new file mode 100644 index 0000000000..3aade7a5ad --- /dev/null +++ b/api/v1alpha1/status.go @@ -0,0 +1,86 @@ +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Conditions is the schema for the conditions portion of the payload +type Conditions []Condition + +// ConditionType is a camel-cased condition type. +type ConditionType string + +const ( + // ConditionSynced specifies that the resource has been synced with Consul. + ConditionSynced ConditionType = "Synced" +) + +// 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 +// +k8s:deepcopy-gen=true +// +k8s:openapi-gen=true +type Condition struct { + // Type of condition. + // +required + Type ConditionType `json:"type" description:"type of status condition"` + + // Status of the condition, one of True, False, Unknown. + // +required + Status corev1.ConditionStatus `json:"status" description:"status of the condition, one of True, False, Unknown"` + + // LastTransitionTime is the last time the condition transitioned from one status to another. + // +optional + LastTransitionTime metav1.Time `json:"lastTransitionTime,omitempty" description:"last time the condition transitioned from one status to another"` + + // The reason for the condition's last transition. + // +optional + Reason string `json:"reason,omitempty" description:"one-word CamelCase reason for the condition's last transition"` + + // A human readable message indicating details about the transition. + // +optional + Message string `json:"message,omitempty" description:"human-readable message indicating details about last transition"` +} + +// IsTrue is true if the condition is True +func (c *Condition) IsTrue() bool { + if c == nil { + return false + } + return c.Status == corev1.ConditionTrue +} + +// IsFalse is true if the condition is False +func (c *Condition) IsFalse() bool { + if c == nil { + return false + } + return c.Status == corev1.ConditionFalse +} + +// IsUnknown is true if the condition is Unknown +func (c *Condition) IsUnknown() bool { + if c == nil { + return true + } + return c.Status == corev1.ConditionUnknown +} + +// +k8s:deepcopy-gen=true +// +k8s:openapi-gen=true +type Status 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"` +} + +func (s *Status) GetCondition(t ConditionType) *Condition { + for _, cond := range s.Conditions { + if cond.Type == t { + return &cond + } + } + return nil +} diff --git a/api/v1alpha1/types.go b/api/v1alpha1/types.go new file mode 100644 index 0000000000..3eb9cef47d --- /dev/null +++ b/api/v1alpha1/types.go @@ -0,0 +1,86 @@ +package v1alpha1 + +import ( + "fmt" + "strings" + + "github.com/hashicorp/consul-k8s/api/common" + capi "github.com/hashicorp/consul/api" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +type MeshGatewayMode string + +const ( + // MeshGatewayModeDefault represents no specific mode and should + // be used to indicate that a different layer of the configuration + // chain should take precedence + MeshGatewayModeDefault MeshGatewayMode = "" + + // MeshGatewayModeNone represents that the Upstream Connect connections + // should be direct and not flow through a mesh gateway. + MeshGatewayModeNone MeshGatewayMode = "none" + + // MeshGatewayModeLocal represents that the Upstream Connect connections + // should be made to a mesh gateway in the local datacenter. + MeshGatewayModeLocal MeshGatewayMode = "local" + + // MeshGatewayModeRemote represents that the Upstream Connect connections + // should be made to a mesh gateway in a remote datacenter. + MeshGatewayModeRemote MeshGatewayMode = "remote" +) + +// MeshGatewayConfig controls how Mesh Gateways are used for upstream Connect +// services +type MeshGatewayConfig struct { + // Mode is the mode that should be used for the upstream connection. + // One of none, local, or remote. + Mode string `json:"mode,omitempty"` +} + +// toConsul returns the MeshGatewayConfig for the entry +func (m MeshGatewayConfig) toConsul() capi.MeshGatewayConfig { + mode := capi.MeshGatewayMode(m.Mode) + switch mode { + case capi.MeshGatewayModeLocal, capi.MeshGatewayModeRemote, capi.MeshGatewayModeNone: + return capi.MeshGatewayConfig{ + Mode: mode, + } + default: + return capi.MeshGatewayConfig{ + Mode: capi.MeshGatewayModeDefault, + } + } +} + +func (m MeshGatewayConfig) validate(path *field.Path) *field.Error { + modes := []string{"remote", "local", "none", ""} + if !sliceContains(modes, m.Mode) { + return field.Invalid(path.Child("mode"), m.Mode, notInSliceMessage(modes)) + } + return nil +} + +func notInSliceMessage(slice []string) string { + return fmt.Sprintf(`must be one of "%s"`, strings.Join(slice, `", "`)) +} + +func sliceContains(slice []string, entry string) bool { + for _, s := range slice { + if entry == s { + return true + } + } + return false +} + +func invalidPathPrefix(path string) bool { + return path != "" && !strings.HasPrefix(path, "/") +} + +func meta(datacenter string) map[string]string { + return map[string]string{ + common.SourceKey: common.SourceValue, + common.DatacenterKey: datacenter, + } +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000000..89a302f6c4 --- /dev/null +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,1138 @@ +// +build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "encoding/json" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Condition) DeepCopyInto(out *Condition) { + *out = *in + in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Condition. +func (in *Condition) DeepCopy() *Condition { + if in == nil { + return nil + } + out := new(Condition) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in Conditions) DeepCopyInto(out *Conditions) { + { + in := &in + *out = make(Conditions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Conditions. +func (in Conditions) DeepCopy() Conditions { + if in == nil { + return nil + } + out := new(Conditions) + in.DeepCopyInto(out) + return *out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CookieConfig) DeepCopyInto(out *CookieConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CookieConfig. +func (in *CookieConfig) DeepCopy() *CookieConfig { + if in == nil { + return nil + } + out := new(CookieConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Destination) DeepCopyInto(out *Destination) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Destination. +func (in *Destination) DeepCopy() *Destination { + if in == nil { + return nil + } + out := new(Destination) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExposeConfig) DeepCopyInto(out *ExposeConfig) { + *out = *in + if in.Paths != nil { + in, out := &in.Paths, &out.Paths + *out = make([]ExposePath, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExposeConfig. +func (in *ExposeConfig) DeepCopy() *ExposeConfig { + if in == nil { + return nil + } + out := new(ExposeConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExposePath) DeepCopyInto(out *ExposePath) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExposePath. +func (in *ExposePath) DeepCopy() *ExposePath { + if in == nil { + return nil + } + out := new(ExposePath) + 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 + if in.CookieConfig != nil { + in, out := &in.CookieConfig, &out.CookieConfig + *out = new(CookieConfig) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HashPolicy. +func (in *HashPolicy) DeepCopy() *HashPolicy { + if in == nil { + return nil + } + out := new(HashPolicy) + 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 +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IntentionHTTPHeaderPermission. +func (in *IntentionHTTPHeaderPermission) DeepCopy() *IntentionHTTPHeaderPermission { + if in == nil { + return nil + } + out := new(IntentionHTTPHeaderPermission) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in IntentionHTTPHeaderPermissions) DeepCopyInto(out *IntentionHTTPHeaderPermissions) { + { + in := &in + *out = make(IntentionHTTPHeaderPermissions, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IntentionHTTPHeaderPermissions. +func (in IntentionHTTPHeaderPermissions) DeepCopy() IntentionHTTPHeaderPermissions { + if in == nil { + return nil + } + out := new(IntentionHTTPHeaderPermissions) + in.DeepCopyInto(out) + return *out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IntentionHTTPPermission) DeepCopyInto(out *IntentionHTTPPermission) { + *out = *in + if in.Header != nil { + in, out := &in.Header, &out.Header + *out = make(IntentionHTTPHeaderPermissions, len(*in)) + copy(*out, *in) + } + if in.Methods != nil { + in, out := &in.Methods, &out.Methods + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IntentionHTTPPermission. +func (in *IntentionHTTPPermission) DeepCopy() *IntentionHTTPPermission { + if in == nil { + return nil + } + out := new(IntentionHTTPPermission) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IntentionPermission) DeepCopyInto(out *IntentionPermission) { + *out = *in + if in.HTTP != nil { + in, out := &in.HTTP, &out.HTTP + *out = new(IntentionHTTPPermission) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IntentionPermission. +func (in *IntentionPermission) DeepCopy() *IntentionPermission { + if in == nil { + return nil + } + out := new(IntentionPermission) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in IntentionPermissions) DeepCopyInto(out *IntentionPermissions) { + { + in := &in + *out = make(IntentionPermissions, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(IntentionPermission) + (*in).DeepCopyInto(*out) + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IntentionPermissions. +func (in IntentionPermissions) DeepCopy() IntentionPermissions { + if in == nil { + return nil + } + out := new(IntentionPermissions) + in.DeepCopyInto(out) + return *out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LeastRequestConfig) DeepCopyInto(out *LeastRequestConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LeastRequestConfig. +func (in *LeastRequestConfig) DeepCopy() *LeastRequestConfig { + if in == nil { + return nil + } + out := new(LeastRequestConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LoadBalancer) DeepCopyInto(out *LoadBalancer) { + *out = *in + if in.RingHashConfig != nil { + in, out := &in.RingHashConfig, &out.RingHashConfig + *out = new(RingHashConfig) + **out = **in + } + if in.LeastRequestConfig != nil { + in, out := &in.LeastRequestConfig, &out.LeastRequestConfig + *out = new(LeastRequestConfig) + **out = **in + } + if in.HashPolicies != nil { + in, out := &in.HashPolicies, &out.HashPolicies + *out = make([]HashPolicy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LoadBalancer. +func (in *LoadBalancer) DeepCopy() *LoadBalancer { + if in == nil { + return nil + } + out := new(LoadBalancer) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MeshGatewayConfig) DeepCopyInto(out *MeshGatewayConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MeshGatewayConfig. +func (in *MeshGatewayConfig) DeepCopy() *MeshGatewayConfig { + if in == nil { + return nil + } + out := new(MeshGatewayConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProxyDefaults) DeepCopyInto(out *ProxyDefaults) { + *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 ProxyDefaults. +func (in *ProxyDefaults) DeepCopy() *ProxyDefaults { + if in == nil { + return nil + } + out := new(ProxyDefaults) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ProxyDefaults) 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 *ProxyDefaultsList) DeepCopyInto(out *ProxyDefaultsList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ProxyDefaults, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxyDefaultsList. +func (in *ProxyDefaultsList) DeepCopy() *ProxyDefaultsList { + if in == nil { + return nil + } + out := new(ProxyDefaultsList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ProxyDefaultsList) 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 *ProxyDefaultsSpec) DeepCopyInto(out *ProxyDefaultsSpec) { + *out = *in + if in.Config != nil { + in, out := &in.Config, &out.Config + *out = make(json.RawMessage, len(*in)) + copy(*out, *in) + } + out.MeshGateway = in.MeshGateway + in.Expose.DeepCopyInto(&out.Expose) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxyDefaultsSpec. +func (in *ProxyDefaultsSpec) DeepCopy() *ProxyDefaultsSpec { + if in == nil { + return nil + } + out := new(ProxyDefaultsSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RingHashConfig) DeepCopyInto(out *RingHashConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RingHashConfig. +func (in *RingHashConfig) DeepCopy() *RingHashConfig { + if in == nil { + return nil + } + out := new(RingHashConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceDefaults) DeepCopyInto(out *ServiceDefaults) { + *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 ServiceDefaults. +func (in *ServiceDefaults) DeepCopy() *ServiceDefaults { + if in == nil { + return nil + } + out := new(ServiceDefaults) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceDefaults) 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 *ServiceDefaultsList) DeepCopyInto(out *ServiceDefaultsList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ServiceDefaults, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceDefaultsList. +func (in *ServiceDefaultsList) DeepCopy() *ServiceDefaultsList { + if in == nil { + return nil + } + out := new(ServiceDefaultsList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceDefaultsList) 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 *ServiceDefaultsSpec) DeepCopyInto(out *ServiceDefaultsSpec) { + *out = *in + out.MeshGateway = in.MeshGateway + in.Expose.DeepCopyInto(&out.Expose) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceDefaultsSpec. +func (in *ServiceDefaultsSpec) DeepCopy() *ServiceDefaultsSpec { + if in == nil { + return nil + } + out := new(ServiceDefaultsSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceIntentions) DeepCopyInto(out *ServiceIntentions) { + *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 ServiceIntentions. +func (in *ServiceIntentions) DeepCopy() *ServiceIntentions { + if in == nil { + return nil + } + out := new(ServiceIntentions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceIntentions) 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 *ServiceIntentionsList) DeepCopyInto(out *ServiceIntentionsList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ServiceIntentions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceIntentionsList. +func (in *ServiceIntentionsList) DeepCopy() *ServiceIntentionsList { + if in == nil { + return nil + } + out := new(ServiceIntentionsList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceIntentionsList) 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 *ServiceIntentionsSpec) DeepCopyInto(out *ServiceIntentionsSpec) { + *out = *in + out.Destination = in.Destination + if in.Sources != nil { + in, out := &in.Sources, &out.Sources + *out = make(SourceIntentions, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(SourceIntention) + (*in).DeepCopyInto(*out) + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceIntentionsSpec. +func (in *ServiceIntentionsSpec) DeepCopy() *ServiceIntentionsSpec { + if in == nil { + return nil + } + out := new(ServiceIntentionsSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceResolver) DeepCopyInto(out *ServiceResolver) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceResolver. +func (in *ServiceResolver) DeepCopy() *ServiceResolver { + if in == nil { + return nil + } + out := new(ServiceResolver) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceResolver) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceResolverFailover) DeepCopyInto(out *ServiceResolverFailover) { + *out = *in + if in.Datacenters != nil { + in, out := &in.Datacenters, &out.Datacenters + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceResolverFailover. +func (in *ServiceResolverFailover) DeepCopy() *ServiceResolverFailover { + if in == nil { + return nil + } + out := new(ServiceResolverFailover) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in ServiceResolverFailoverMap) DeepCopyInto(out *ServiceResolverFailoverMap) { + { + in := &in + *out = make(ServiceResolverFailoverMap, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceResolverFailoverMap. +func (in ServiceResolverFailoverMap) DeepCopy() ServiceResolverFailoverMap { + if in == nil { + return nil + } + out := new(ServiceResolverFailoverMap) + in.DeepCopyInto(out) + return *out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceResolverList) DeepCopyInto(out *ServiceResolverList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ServiceResolver, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceResolverList. +func (in *ServiceResolverList) DeepCopy() *ServiceResolverList { + if in == nil { + return nil + } + out := new(ServiceResolverList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceResolverList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceResolverRedirect) DeepCopyInto(out *ServiceResolverRedirect) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceResolverRedirect. +func (in *ServiceResolverRedirect) DeepCopy() *ServiceResolverRedirect { + if in == nil { + return nil + } + out := new(ServiceResolverRedirect) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceResolverSpec) DeepCopyInto(out *ServiceResolverSpec) { + *out = *in + if in.Subsets != nil { + in, out := &in.Subsets, &out.Subsets + *out = make(ServiceResolverSubsetMap, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Redirect != nil { + in, out := &in.Redirect, &out.Redirect + *out = new(ServiceResolverRedirect) + **out = **in + } + if in.Failover != nil { + in, out := &in.Failover, &out.Failover + *out = make(ServiceResolverFailoverMap, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } + if in.LoadBalancer != nil { + in, out := &in.LoadBalancer, &out.LoadBalancer + *out = new(LoadBalancer) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceResolverSpec. +func (in *ServiceResolverSpec) DeepCopy() *ServiceResolverSpec { + if in == nil { + return nil + } + out := new(ServiceResolverSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceResolverSubset) DeepCopyInto(out *ServiceResolverSubset) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceResolverSubset. +func (in *ServiceResolverSubset) DeepCopy() *ServiceResolverSubset { + if in == nil { + return nil + } + out := new(ServiceResolverSubset) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in ServiceResolverSubsetMap) DeepCopyInto(out *ServiceResolverSubsetMap) { + { + in := &in + *out = make(ServiceResolverSubsetMap, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceResolverSubsetMap. +func (in ServiceResolverSubsetMap) DeepCopy() ServiceResolverSubsetMap { + if in == nil { + return nil + } + out := new(ServiceResolverSubsetMap) + in.DeepCopyInto(out) + return *out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceRoute) DeepCopyInto(out *ServiceRoute) { + *out = *in + if in.Match != nil { + in, out := &in.Match, &out.Match + *out = new(ServiceRouteMatch) + (*in).DeepCopyInto(*out) + } + if in.Destination != nil { + in, out := &in.Destination, &out.Destination + *out = new(ServiceRouteDestination) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceRoute. +func (in *ServiceRoute) DeepCopy() *ServiceRoute { + if in == nil { + return nil + } + out := new(ServiceRoute) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceRouteDestination) DeepCopyInto(out *ServiceRouteDestination) { + *out = *in + if in.RetryOnStatusCodes != nil { + in, out := &in.RetryOnStatusCodes, &out.RetryOnStatusCodes + *out = make([]uint32, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceRouteDestination. +func (in *ServiceRouteDestination) DeepCopy() *ServiceRouteDestination { + if in == nil { + return nil + } + out := new(ServiceRouteDestination) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceRouteHTTPMatch) DeepCopyInto(out *ServiceRouteHTTPMatch) { + *out = *in + if in.Header != nil { + in, out := &in.Header, &out.Header + *out = make([]ServiceRouteHTTPMatchHeader, len(*in)) + copy(*out, *in) + } + if in.QueryParam != nil { + in, out := &in.QueryParam, &out.QueryParam + *out = make([]ServiceRouteHTTPMatchQueryParam, len(*in)) + copy(*out, *in) + } + if in.Methods != nil { + in, out := &in.Methods, &out.Methods + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceRouteHTTPMatch. +func (in *ServiceRouteHTTPMatch) DeepCopy() *ServiceRouteHTTPMatch { + if in == nil { + return nil + } + out := new(ServiceRouteHTTPMatch) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceRouteHTTPMatchHeader) DeepCopyInto(out *ServiceRouteHTTPMatchHeader) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceRouteHTTPMatchHeader. +func (in *ServiceRouteHTTPMatchHeader) DeepCopy() *ServiceRouteHTTPMatchHeader { + if in == nil { + return nil + } + out := new(ServiceRouteHTTPMatchHeader) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceRouteHTTPMatchQueryParam) DeepCopyInto(out *ServiceRouteHTTPMatchQueryParam) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceRouteHTTPMatchQueryParam. +func (in *ServiceRouteHTTPMatchQueryParam) DeepCopy() *ServiceRouteHTTPMatchQueryParam { + if in == nil { + return nil + } + out := new(ServiceRouteHTTPMatchQueryParam) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceRouteMatch) DeepCopyInto(out *ServiceRouteMatch) { + *out = *in + if in.HTTP != nil { + in, out := &in.HTTP, &out.HTTP + *out = new(ServiceRouteHTTPMatch) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceRouteMatch. +func (in *ServiceRouteMatch) DeepCopy() *ServiceRouteMatch { + if in == nil { + return nil + } + out := new(ServiceRouteMatch) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceRouter) DeepCopyInto(out *ServiceRouter) { + *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 ServiceRouter. +func (in *ServiceRouter) DeepCopy() *ServiceRouter { + if in == nil { + return nil + } + out := new(ServiceRouter) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceRouter) 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 *ServiceRouterList) DeepCopyInto(out *ServiceRouterList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ServiceRouter, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceRouterList. +func (in *ServiceRouterList) DeepCopy() *ServiceRouterList { + if in == nil { + return nil + } + out := new(ServiceRouterList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceRouterList) 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 *ServiceRouterSpec) DeepCopyInto(out *ServiceRouterSpec) { + *out = *in + if in.Routes != nil { + in, out := &in.Routes, &out.Routes + *out = make([]ServiceRoute, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceRouterSpec. +func (in *ServiceRouterSpec) DeepCopy() *ServiceRouterSpec { + if in == nil { + return nil + } + out := new(ServiceRouterSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceSplit) DeepCopyInto(out *ServiceSplit) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceSplit. +func (in *ServiceSplit) DeepCopy() *ServiceSplit { + if in == nil { + return nil + } + out := new(ServiceSplit) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in ServiceSplits) DeepCopyInto(out *ServiceSplits) { + { + in := &in + *out = make(ServiceSplits, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceSplits. +func (in ServiceSplits) DeepCopy() ServiceSplits { + if in == nil { + return nil + } + out := new(ServiceSplits) + in.DeepCopyInto(out) + return *out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceSplitter) DeepCopyInto(out *ServiceSplitter) { + *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 ServiceSplitter. +func (in *ServiceSplitter) DeepCopy() *ServiceSplitter { + if in == nil { + return nil + } + out := new(ServiceSplitter) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceSplitter) 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 *ServiceSplitterList) DeepCopyInto(out *ServiceSplitterList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ServiceSplitter, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceSplitterList. +func (in *ServiceSplitterList) DeepCopy() *ServiceSplitterList { + if in == nil { + return nil + } + out := new(ServiceSplitterList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceSplitterList) 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 *ServiceSplitterSpec) DeepCopyInto(out *ServiceSplitterSpec) { + *out = *in + if in.Splits != nil { + in, out := &in.Splits, &out.Splits + *out = make(ServiceSplits, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceSplitterSpec. +func (in *ServiceSplitterSpec) DeepCopy() *ServiceSplitterSpec { + if in == nil { + return nil + } + out := new(ServiceSplitterSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SourceIntention) DeepCopyInto(out *SourceIntention) { + *out = *in + if in.Permissions != nil { + in, out := &in.Permissions, &out.Permissions + *out = make(IntentionPermissions, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(IntentionPermission) + (*in).DeepCopyInto(*out) + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SourceIntention. +func (in *SourceIntention) DeepCopy() *SourceIntention { + if in == nil { + return nil + } + out := new(SourceIntention) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in SourceIntentions) DeepCopyInto(out *SourceIntentions) { + { + in := &in + *out = make(SourceIntentions, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(SourceIntention) + (*in).DeepCopyInto(*out) + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SourceIntentions. +func (in SourceIntentions) DeepCopy() SourceIntentions { + if in == nil { + return nil + } + out := new(SourceIntentions) + in.DeepCopyInto(out) + return *out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Status) DeepCopyInto(out *Status) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(Conditions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Status. +func (in *Status) DeepCopy() *Status { + if in == nil { + return nil + } + out := new(Status) + in.DeepCopyInto(out) + return out +} diff --git a/build-support/controller/README.md b/build-support/controller/README.md new file mode 100644 index 0000000000..0d24937531 --- /dev/null +++ b/build-support/controller/README.md @@ -0,0 +1,5 @@ +## Overview + +`boilerplate.go.txt` is a file required by `operator-sdk` when it performs code-generation. + +It's contents provide the headers to the generated files but as we do not require headers for the files we generate, it has been left intentionally blank. diff --git a/build-support/controller/boilerplate.go.txt b/build-support/controller/boilerplate.go.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/catalog/to-consul/resource.go b/catalog/to-consul/resource.go index ab35f1b23e..5a6abb810f 100644 --- a/catalog/to-consul/resource.go +++ b/catalog/to-consul/resource.go @@ -9,6 +9,7 @@ import ( mapset "github.com/deckarep/golang-set" "github.com/hashicorp/consul-k8s/helper/controller" + "github.com/hashicorp/consul-k8s/namespaces" consulapi "github.com/hashicorp/consul/api" "github.com/hashicorp/go-hclog" apiv1 "k8s.io/api/core/v1" @@ -357,19 +358,14 @@ func (t *ServiceResource) generateRegistrations(key string) { } // Update the Consul namespace based on namespace settings - if t.EnableNamespaces { - var ns string - - // Mirroring takes precedence - if t.EnableK8SNSMirroring { - ns = fmt.Sprintf("%s%s", t.K8SNSMirroringPrefix, svc.Namespace) - } else { - ns = t.ConsulDestinationNamespace - } - t.Log.Debug("[generateRegistrations] namespace being used", "key", key, "namespace", ns) - - // Update baseService to have a Consul namespace - baseService.Namespace = ns + consulNS := namespaces.ConsulNamespace(svc.Namespace, + t.EnableNamespaces, + t.ConsulDestinationNamespace, + t.EnableK8SNSMirroring, + t.K8SNSMirroringPrefix) + if consulNS != "" { + t.Log.Debug("[generateRegistrations] namespace being used", "key", key, "namespace", consulNS) + baseService.Namespace = consulNS } // Determine the default port and set port annotations diff --git a/catalog/to-consul/syncer.go b/catalog/to-consul/syncer.go index cfb628f76d..a867a0a80c 100644 --- a/catalog/to-consul/syncer.go +++ b/catalog/to-consul/syncer.go @@ -7,6 +7,7 @@ import ( "github.com/cenkalti/backoff" "github.com/deckarep/golang-set" + "github.com/hashicorp/consul-k8s/namespaces" "github.com/hashicorp/consul/api" "github.com/hashicorp/go-hclog" ) @@ -416,9 +417,7 @@ func (s *ConsulSyncer) syncFull(ctx context.Context) { for _, services := range s.namespaces { for _, r := range services { if s.EnableNamespaces { - // Check and potentially create the service's namespace if - // it doesn't already exist - err := s.checkAndCreateNamespace(r.Service.Namespace) + _, err := namespaces.EnsureExists(s.Client, r.Service.Namespace, s.CrossNamespaceACLPolicy) if err != nil { s.Log.Warn("error checking and creating Consul namespace", "node-name", r.Node, @@ -474,40 +473,3 @@ func (s *ConsulSyncer) init() { s.initialSync = make(chan bool) } } - -func (s *ConsulSyncer) checkAndCreateNamespace(ns string) error { - // Check if the Consul namespace exists - namespaceInfo, _, err := s.Client.Namespaces().Read(ns, nil) - if err != nil { - return err - } - - // If not, create it - if namespaceInfo == nil { - var aclConfig api.NamespaceACLConfig - if s.CrossNamespaceACLPolicy != "" { - // Create the ACLs config for the cross-Consul-namespace - // default policy that needs to be attached - aclConfig = api.NamespaceACLConfig{ - PolicyDefaults: []api.ACLLink{ - {Name: s.CrossNamespaceACLPolicy}, - }, - } - } - - consulNamespace := api.Namespace{ - Name: ns, - Description: "Auto-generated by a Catalog Sync Process", - ACLs: &aclConfig, - Meta: map[string]string{"external-source": "kubernetes"}, - } - - _, _, err = s.Client.Namespaces().Create(&consulNamespace, nil) - if err != nil { - return err - } - s.Log.Info("creating consul namespace", "name", consulNamespace.Name) - } - - return nil -} diff --git a/commands.go b/commands.go index cd0f825837..d4fe64250a 100644 --- a/commands.go +++ b/commands.go @@ -4,6 +4,7 @@ import ( "os" cmdACLInit "github.com/hashicorp/consul-k8s/subcommand/acl-init" + cmdController "github.com/hashicorp/consul-k8s/subcommand/controller" cmdCreateFederationSecret "github.com/hashicorp/consul-k8s/subcommand/create-federation-secret" cmdDeleteCompletedJob "github.com/hashicorp/consul-k8s/subcommand/delete-completed-job" cmdGetConsulClientCA "github.com/hashicorp/consul-k8s/subcommand/get-consul-client-ca" @@ -13,6 +14,7 @@ import ( cmdServiceAddress "github.com/hashicorp/consul-k8s/subcommand/service-address" cmdSyncCatalog "github.com/hashicorp/consul-k8s/subcommand/sync-catalog" cmdVersion "github.com/hashicorp/consul-k8s/subcommand/version" + webhookCertManager "github.com/hashicorp/consul-k8s/subcommand/webhook-cert-manager" "github.com/hashicorp/consul-k8s/version" "github.com/mitchellh/cli" ) @@ -63,6 +65,13 @@ func init() { "create-federation-secret": func() (cli.Command, error) { return &cmdCreateFederationSecret.Command{UI: ui}, nil }, + + "controller": func() (cli.Command, error) { + return &cmdController.Command{UI: ui}, nil + }, + "webhook-cert-manager": func() (cli.Command, error) { + return &webhookCertManager.Command{UI: ui}, nil + }, } } diff --git a/config/certmanager/certificate.yaml b/config/certmanager/certificate.yaml new file mode 100644 index 0000000000..58db114fa0 --- /dev/null +++ b/config/certmanager/certificate.yaml @@ -0,0 +1,26 @@ +# The following manifests contain a self-signed issuer CR and a certificate CR. +# More document can be found at https://docs.cert-manager.io +# WARNING: Targets CertManager 0.11 check https://docs.cert-manager.io/en/latest/tasks/upgrading/index.html for +# breaking changes +apiVersion: cert-manager.io/v1alpha2 +kind: Issuer +metadata: + name: selfsigned-issuer + namespace: system +spec: + selfSigned: {} +--- +apiVersion: cert-manager.io/v1alpha2 +kind: Certificate +metadata: + name: serving-cert # this name should match the one appeared in kustomizeconfig.yaml + namespace: system +spec: + # $(SERVICE_NAME) and $(SERVICE_NAMESPACE) will be substituted by kustomize + dnsNames: + - $(SERVICE_NAME).$(SERVICE_NAMESPACE).svc + - $(SERVICE_NAME).$(SERVICE_NAMESPACE).svc.cluster.local + issuerRef: + kind: Issuer + name: selfsigned-issuer + secretName: webhook-server-cert # this secret will not be prefixed, since it's not managed by kustomize diff --git a/config/certmanager/kustomization.yaml b/config/certmanager/kustomization.yaml new file mode 100644 index 0000000000..bebea5a595 --- /dev/null +++ b/config/certmanager/kustomization.yaml @@ -0,0 +1,5 @@ +resources: +- certificate.yaml + +configurations: +- kustomizeconfig.yaml diff --git a/config/certmanager/kustomizeconfig.yaml b/config/certmanager/kustomizeconfig.yaml new file mode 100644 index 0000000000..90d7c313ca --- /dev/null +++ b/config/certmanager/kustomizeconfig.yaml @@ -0,0 +1,16 @@ +# This configuration is for teaching kustomize how to update name ref and var substitution +nameReference: +- kind: Issuer + group: cert-manager.io + fieldSpecs: + - kind: Certificate + group: cert-manager.io + path: spec/issuerRef/name + +varReference: +- kind: Certificate + group: cert-manager.io + path: spec/commonName +- kind: Certificate + group: cert-manager.io + path: spec/dnsNames diff --git a/config/crd/bases/consul.hashicorp.com_proxydefaults.yaml b/config/crd/bases/consul.hashicorp.com_proxydefaults.yaml new file mode 100644 index 0000000000..91c950dce0 --- /dev/null +++ b/config/crd/bases/consul.hashicorp.com_proxydefaults.yaml @@ -0,0 +1,116 @@ + +--- +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.4.0 + creationTimestamp: null + name: proxydefaults.consul.hashicorp.com +spec: + additionalPrinterColumns: + - JSONPath: .status.conditions[?(@.type=="Synced")].status + description: The sync status of the resource with Consul + name: Synced + type: string + group: consul.hashicorp.com + names: + kind: ProxyDefaults + listKind: ProxyDefaultsList + plural: proxydefaults + singular: proxydefaults + scope: Namespaced + subresources: + status: {} + validation: + openAPIV3Schema: + description: ProxyDefaults is the Schema for the proxydefaults 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: ProxyDefaultsSpec defines the desired state of ProxyDefaults + properties: + config: + description: Config is an arbitrary map of configuration values used by Connect proxies. Any values that your proxy allows can be configured globally here. Supports JSON config values. See https://www.consul.io/docs/connect/proxies/envoy#configuration-formatting + type: Any + expose: + description: Expose controls the default expose path configuration for Envoy. + properties: + checks: + description: Checks defines whether paths associated with Consul checks will be exposed. This flag triggers exposing all HTTP and GRPC check paths registered for the service. + type: boolean + paths: + description: Paths is the list of paths exposed through the proxy. + items: + properties: + listenerPort: + description: ListenerPort defines the port of the proxy's listener for exposed paths. + type: integer + localPathPort: + description: LocalPathPort is the port that the service is listening on for the given path. + type: integer + path: + description: Path is the path to expose through the proxy, ie. "/metrics". + type: string + protocol: + description: Protocol describes the upstream's service protocol. Valid values are "http" and "http2", defaults to "http". + type: string + type: object + type: array + type: object + meshGateway: + description: MeshGateway controls the default mesh gateway configuration for this service. + properties: + mode: + description: Mode is the mode that should be used for the upstream connection. One of none, local, or remote. + type: string + 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/bases/consul.hashicorp.com_servicedefaults.yaml b/config/crd/bases/consul.hashicorp.com_servicedefaults.yaml new file mode 100644 index 0000000000..0943a347bb --- /dev/null +++ b/config/crd/bases/consul.hashicorp.com_servicedefaults.yaml @@ -0,0 +1,119 @@ + +--- +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.4.0 + creationTimestamp: null + name: servicedefaults.consul.hashicorp.com +spec: + additionalPrinterColumns: + - JSONPath: .status.conditions[?(@.type=="Synced")].status + description: The sync status of the resource with Consul + name: Synced + type: string + group: consul.hashicorp.com + names: + kind: ServiceDefaults + listKind: ServiceDefaultsList + plural: servicedefaults + singular: servicedefaults + scope: Namespaced + subresources: + status: {} + validation: + openAPIV3Schema: + description: ServiceDefaults is the Schema for the servicedefaults 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: ServiceDefaultsSpec defines the desired state of ServiceDefaults + properties: + expose: + description: Expose controls the default expose path configuration for Envoy. + properties: + checks: + description: Checks defines whether paths associated with Consul checks will be exposed. This flag triggers exposing all HTTP and GRPC check paths registered for the service. + type: boolean + paths: + description: Paths is the list of paths exposed through the proxy. + items: + properties: + listenerPort: + description: ListenerPort defines the port of the proxy's listener for exposed paths. + type: integer + localPathPort: + description: LocalPathPort is the port that the service is listening on for the given path. + type: integer + path: + description: Path is the path to expose through the proxy, ie. "/metrics". + type: string + protocol: + description: Protocol describes the upstream's service protocol. Valid values are "http" and "http2", defaults to "http". + type: string + type: object + type: array + type: object + externalSNI: + description: ExternalSNI is an optional setting that allows for the TLS SNI value to be changed to a non-connect value when federating with an external system. + type: string + meshGateway: + description: MeshGateway controls the default mesh gateway configuration for this service. + properties: + mode: + description: Mode is the mode that should be used for the upstream connection. One of none, local, or remote. + type: string + type: object + protocol: + description: Protocol sets the protocol of the service. This is used by Connect proxies for things like observability features and to unlock usage of the service-splitter and service-router config entries for a service. + type: string + 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/bases/consul.hashicorp.com_serviceintentions.yaml b/config/crd/bases/consul.hashicorp.com_serviceintentions.yaml new file mode 100644 index 0000000000..92603f3b32 --- /dev/null +++ b/config/crd/bases/consul.hashicorp.com_serviceintentions.yaml @@ -0,0 +1,137 @@ + +--- +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.4.0 + creationTimestamp: null + name: serviceintentions.consul.hashicorp.com +spec: + group: consul.hashicorp.com + names: + kind: ServiceIntentions + listKind: ServiceIntentionsList + plural: serviceintentions + singular: serviceintentions + scope: Namespaced + subresources: + status: {} + validation: + openAPIV3Schema: + description: ServiceIntentions is the Schema for the serviceintentions 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: ServiceIntentionsSpec defines the desired state of ServiceIntentions + properties: + destination: + properties: + name: + type: string + namespace: + type: string + type: object + sources: + items: + properties: + action: + description: IntentionAction is the action that the intention represents. This can be "allow" or "deny" to allowlist or denylist intentions. + type: string + description: + type: string + name: + type: string + namespace: + type: string + permissions: + items: + properties: + action: + description: IntentionAction is the action that the intention represents. This can be "allow" or "deny" to allowlist or denylist intentions. + type: string + http: + properties: + header: + items: + properties: + exact: + type: string + invert: + type: boolean + name: + type: string + prefix: + type: string + present: + type: boolean + regex: + type: string + suffix: + type: string + type: object + type: array + methods: + items: + type: string + type: array + pathExact: + type: string + pathPrefix: + type: string + pathRegex: + type: string + type: object + type: object + type: array + type: object + type: array + 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/bases/consul.hashicorp.com_serviceresolvers.yaml b/config/crd/bases/consul.hashicorp.com_serviceresolvers.yaml new file mode 100644 index 0000000000..23f1d7abdb --- /dev/null +++ b/config/crd/bases/consul.hashicorp.com_serviceresolvers.yaml @@ -0,0 +1,195 @@ + +--- +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.4.0 + creationTimestamp: null + name: serviceresolvers.consul.hashicorp.com +spec: + additionalPrinterColumns: + - JSONPath: .status.conditions[?(@.type=="Synced")].status + description: The sync status of the resource with Consul + name: Synced + type: string + group: consul.hashicorp.com + names: + kind: ServiceResolver + listKind: ServiceResolverList + plural: serviceresolvers + singular: serviceresolver + scope: Namespaced + subresources: + status: {} + validation: + openAPIV3Schema: + description: ServiceResolver is the Schema for the serviceresolvers API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: ServiceResolverSpec defines the desired state of ServiceResolver + properties: + connectTimeout: + description: ConnectTimeout is the timeout for establishing new network connections to this service. + format: int64 + type: integer + defaultSubset: + description: DefaultSubset is the subset to use when no explicit subset is requested. If empty the unnamed subset is used. + type: string + failover: + additionalProperties: + properties: + datacenters: + description: Datacenters is a fixed list of datacenters to try during failover. + items: + type: string + type: array + namespaces: + description: Namespace is the namespace to resolve the requested service from to form the failover group of instances. If empty the current namespace is used. + type: string + service: + description: Service is the service to resolve instead of the default as the failover group of instances during failover. + type: string + serviceSubset: + description: ServiceSubset is the named subset of the requested service to resolve as the failover group of instances. If empty the default subset for the requested service is used. + type: string + type: object + description: Failover controls when and how to reroute traffic to an alternate pool of service instances. The map is keyed by the service subset it applies to and the special string "*" is a wildcard that applies to any subset not otherwise specified here. + type: object + loadBalancer: + description: LoadBalancer determines the load balancing policy and configuration for services issuing requests to this upstream service. + properties: + hashPolicies: + description: HashPolicies is a list of hash policies to use for hashing load balancing algorithms. Hash policies are evaluated individually and combined such that identical lists result in the same hash. If no hash policies are present, or none are successfully evaluated, then a random backend host will be selected. + items: + properties: + cookieConfig: + description: CookieConfig contains configuration for the "cookie" hash policy type. + properties: + path: + description: Path is the path to set for the cookie. + type: string + session: + description: Session determines whether to generate a session cookie with no expiration. + type: boolean + ttl: + description: TTL is the ttl for generated cookies. Cannot be specified for session cookies. + format: int64 + type: integer + type: object + field: + description: Field is the attribute type to hash on. Must be one of "header", "cookie", or "query_parameter". Cannot be specified along with sourceIP. + type: string + fieldValue: + description: FieldValue is the value to hash. ie. header name, cookie name, URL query parameter name Cannot be specified along with sourceIP. + type: string + sourceIP: + description: SourceIP determines whether the hash should be of the source IP rather than of a field and field value. Cannot be specified along with field or fieldValue. + type: boolean + terminal: + description: Terminal will short circuit the computation of the hash when multiple hash policies are present. If a hash is computed when a Terminal policy is evaluated, then that hash will be used and subsequent hash policies will be ignored. + type: boolean + type: object + type: array + leastRequestConfig: + description: LeastRequestConfig contains configuration for the "leastRequest" policy type. + properties: + choiceCount: + description: ChoiceCount determines the number of random healthy hosts from which to select the one with the least requests. + format: int32 + type: integer + type: object + policy: + description: Policy is the load balancing policy used to select a host. + type: string + ringHashConfig: + description: RingHashConfig contains configuration for the "ringHash" policy type. + properties: + maximumRingSize: + description: MaximumRingSize determines the maximum number of entries in the hash ring. + format: int64 + type: integer + minimumRingSize: + description: MinimumRingSize determines the minimum number of entries in the hash ring. + format: int64 + type: integer + type: object + type: object + redirect: + description: Redirect when configured, all attempts to resolve the service this resolver defines will be substituted for the supplied redirect EXCEPT when the redirect has already been applied. When substituting the supplied redirect, all other fields besides Kind, Name, and Redirect will be ignored. + properties: + datacenter: + description: Datacenter is the datacenter to resolve the service from instead of the current one. + type: string + namespace: + description: Namespace is the namespace to resolve the service from instead of the current one. + type: string + service: + description: Service is a service to resolve instead of the current service. + type: string + serviceSubset: + description: ServiceSubset is a named subset of the given service to resolve instead of one defined as that service's DefaultSubset If empty the default subset is used. + type: string + type: object + subsets: + additionalProperties: + properties: + filter: + description: Filter is the filter expression to be used for selecting instances of the requested service. If empty all healthy instances are returned. This expression can filter on the same selectors as the Health API endpoint. + type: string + onlyPassing: + description: OnlyPassing specifies the behavior of the resolver's health check interpretation. If this is set to false, instances with checks in the passing as well as the warning states will be considered healthy. If this is set to true, only instances with checks in the passing state will be considered healthy. + type: boolean + type: object + description: Subsets is map of subset name to subset definition for all usable named subsets of this service. The map key is the name of the subset and all names must be valid DNS subdomain elements. This may be empty, in which case only the unnamed default subset will be usable. + type: object + type: object + status: + properties: + conditions: + description: Conditions indicate the latest available observations of a resource's current state. + items: + description: 'Conditions define a readiness condition for a Consul resource. See: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties' + properties: + lastTransitionTime: + description: LastTransitionTime is the last time the condition transitioned from one status to another. + format: date-time + type: string + message: + description: A human readable message indicating details about the transition. + type: string + reason: + description: The reason for the condition's last transition. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: Type of condition. + type: string + required: + - status + - type + type: object + type: array + type: object + type: object + version: v1alpha1 + versions: + - name: v1alpha1 + served: true + storage: true +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/config/crd/bases/consul.hashicorp.com_servicerouters.yaml b/config/crd/bases/consul.hashicorp.com_servicerouters.yaml new file mode 100644 index 0000000000..d7c5bc6779 --- /dev/null +++ b/config/crd/bases/consul.hashicorp.com_servicerouters.yaml @@ -0,0 +1,191 @@ + +--- +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.4.0 + creationTimestamp: null + name: servicerouters.consul.hashicorp.com +spec: + additionalPrinterColumns: + - JSONPath: .status.conditions[?(@.type=="Synced")].status + description: The sync status of the resource with Consul + name: Synced + type: string + group: consul.hashicorp.com + names: + kind: ServiceRouter + listKind: ServiceRouterList + plural: servicerouters + singular: servicerouter + scope: Namespaced + subresources: + status: {} + validation: + openAPIV3Schema: + description: ServiceRouter is the Schema for the servicerouters 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: ServiceRouterSpec defines the desired state of ServiceRouter + properties: + routes: + description: Routes are the list of routes to consider when processing L7 requests. The first route to match in the list is terminal and stops further evaluation. Traffic that fails to match any of the provided routes will be routed to the default service. + items: + properties: + destination: + description: Destination controls how to proxy the matching request(s) to a service. + properties: + namespace: + description: Namespace is the Consul namespace to resolve the service from instead of the current namespace. If empty the current namespace is assumed. + type: string + numRetries: + description: NumRetries is the number of times to retry the request when a retryable result occurs + format: int32 + type: integer + prefixRewrite: + description: PrefixRewrite defines how to rewrite the HTTP request path before proxying it to its final destination. This requires that either match.http.pathPrefix or match.http.pathExact be configured on this route. + type: string + requestTimeout: + description: RequestTimeout is the total amount of time permitted for the entire downstream request (and retries) to be processed. + format: int64 + type: integer + retryOnConnectFailure: + description: RetryOnConnectFailure allows for connection failure errors to trigger a retry. + type: boolean + retryOnStatusCodes: + description: RetryOnStatusCodes is a flat list of http response status codes that are eligible for retry. + items: + format: int32 + type: integer + type: array + service: + description: Service is the service to resolve instead of the default service. If empty then the default service name is used. + type: string + serviceSubset: + description: ServiceSubset is a named subset of the given service to resolve instead of the one defined as that service's DefaultSubset. If empty, the default subset is used. + type: string + type: object + match: + description: Match is a set of criteria that can match incoming L7 requests. If empty or omitted it acts as a catch-all. + properties: + http: + description: HTTP is a set of http-specific match criteria. + properties: + header: + description: Header is a set of criteria that can match on HTTP request headers. If more than one is configured all must match for the overall match to apply. + items: + properties: + exact: + description: Exact will match if the header with the given name is this value. + type: string + invert: + description: Invert inverts the logic of the match. + type: boolean + name: + description: Name is the name of the header to match. + type: string + prefix: + description: Prefix will match if the header with the given name has this prefix. + type: string + present: + description: Present will match if the header with the given name is present with any value. + type: boolean + regex: + description: Regex will match if the header with the given name matches this pattern. + type: string + suffix: + description: Suffix will match if the header with the given name has this suffix. + type: string + required: + - name + type: object + type: array + methods: + description: Methods is a list of HTTP methods for which this match applies. If unspecified all http methods are matched. + items: + type: string + type: array + pathExact: + description: PathExact is an exact path to match on the HTTP request path. + type: string + pathPrefix: + description: PathPrefix is a path prefix to match on the HTTP request path. + type: string + pathRegex: + description: PathRegex is a regular expression to match on the HTTP request path. + type: string + queryParam: + description: QueryParam is a set of criteria that can match on HTTP query parameters. If more than one is configured all must match for the overall match to apply. + items: + properties: + exact: + description: Exact will match if the query parameter with the given name is this value. + type: string + name: + description: Name is the name of the query parameter to match on. + type: string + present: + description: Present will match if the query parameter with the given name is present with any value. + type: boolean + regex: + description: Regex will match if the query parameter with the given name matches this pattern. + type: string + required: + - name + type: object + type: array + type: object + type: object + type: object + type: array + 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/bases/consul.hashicorp.com_servicesplitters.yaml b/config/crd/bases/consul.hashicorp.com_servicesplitters.yaml new file mode 100644 index 0000000000..c55c7453f9 --- /dev/null +++ b/config/crd/bases/consul.hashicorp.com_servicesplitters.yaml @@ -0,0 +1,94 @@ + +--- +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.4.0 + creationTimestamp: null + name: servicesplitters.consul.hashicorp.com +spec: + group: consul.hashicorp.com + names: + kind: ServiceSplitter + listKind: ServiceSplitterList + plural: servicesplitters + singular: servicesplitter + scope: Namespaced + subresources: + status: {} + validation: + openAPIV3Schema: + description: ServiceSplitter is the Schema for the servicesplitters 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: ServiceSplitterSpec defines the desired state of ServiceSplitter + properties: + splits: + description: Splits defines how much traffic to send to which set of service instances during a traffic split. The sum of weights across all splits must add up to 100. + items: + properties: + namespace: + description: The namespace to resolve the service from instead of the current namespace. If empty the current namespace is assumed. + type: string + service: + description: Service is the service to resolve instead of the default. + type: string + serviceSubset: + description: ServiceSubset is a named subset of the given service to resolve instead of one defined as that service's DefaultSubset. If empty the default subset is used. + type: string + weight: + description: Weight is a value between 0 and 100 reflecting what portion of traffic should be directed to this split. The smallest representable weight is 1/10000 or .01%. + type: number + type: object + type: array + 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 new file mode 100644 index 0000000000..490769743e --- /dev/null +++ b/config/crd/kustomization.yaml @@ -0,0 +1,33 @@ +# This kustomization.yaml is not intended to be run by itself, +# since it depends on service name and namespace that are out of this kustomize package. +# It should be run by config/default +resources: +- bases/consul.hashicorp.com_servicedefaults.yaml +- bases/consul.hashicorp.com_serviceresolvers.yaml +- bases/consul.hashicorp.com_proxydefaults.yaml +- bases/consul.hashicorp.com_servicerouters.yaml +- bases/consul.hashicorp.com_serviceintentions.yaml +# +kubebuilder:scaffold:crdkustomizeresource + +patchesStrategicMerge: +# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. +# patches here are for enabling the conversion webhook for each CRD +- patches/webhook_in_servicedefaults.yaml +- patches/webhook_in_serviceresolvers.yaml +- patches/webhook_in_proxydefaults.yaml +- patches/webhook_in_servicerouters.yaml +- patches/webhook_in_serviceintentions.yaml +# +kubebuilder:scaffold:crdkustomizewebhookpatch + +# [CERTMANAGER] To enable webhook, uncomment all the sections with [CERTMANAGER] prefix. +# patches here are for enabling the CA injection for each CRD +#- patches/cainjection_in_servicedefaults.yaml +#- patches/cainjection_in_serviceresolvers.yaml +#- patches/cainjection_in_proxydefaults.yaml +#- patches/cainjection_in_servicerouters.yaml +#- patches/cainjection_in_serviceintentions.yaml +# +kubebuilder:scaffold:crdkustomizecainjectionpatch + +# the following config is for teaching kustomize how to do kustomization for CRDs. +configurations: +- kustomizeconfig.yaml diff --git a/config/crd/kustomizeconfig.yaml b/config/crd/kustomizeconfig.yaml new file mode 100644 index 0000000000..6f83d9a94b --- /dev/null +++ b/config/crd/kustomizeconfig.yaml @@ -0,0 +1,17 @@ +# This file is for teaching kustomize how to substitute name and namespace reference in CRD +nameReference: +- kind: Service + version: v1 + fieldSpecs: + - kind: CustomResourceDefinition + group: apiextensions.k8s.io + path: spec/conversion/webhookClientConfig/service/name + +namespace: +- kind: CustomResourceDefinition + group: apiextensions.k8s.io + path: spec/conversion/webhookClientConfig/service/namespace + create: false + +varReference: +- path: metadata/annotations diff --git a/config/crd/patches/cainjection_in_proxydefaults.yaml b/config/crd/patches/cainjection_in_proxydefaults.yaml new file mode 100644 index 0000000000..df0460c54c --- /dev/null +++ b/config/crd/patches/cainjection_in_proxydefaults.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: proxydefaults.consul.hashicorp.com diff --git a/config/crd/patches/cainjection_in_servicedefaults.yaml b/config/crd/patches/cainjection_in_servicedefaults.yaml new file mode 100644 index 0000000000..3ae80de8ce --- /dev/null +++ b/config/crd/patches/cainjection_in_servicedefaults.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: servicedefaults.consul.hashicorp.com diff --git a/config/crd/patches/cainjection_in_serviceintentions.yaml b/config/crd/patches/cainjection_in_serviceintentions.yaml new file mode 100644 index 0000000000..e53864ab64 --- /dev/null +++ b/config/crd/patches/cainjection_in_serviceintentions.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: serviceintentions.consul.hashicorp.com diff --git a/config/crd/patches/cainjection_in_serviceresolvers.yaml b/config/crd/patches/cainjection_in_serviceresolvers.yaml new file mode 100644 index 0000000000..39a09eb8e1 --- /dev/null +++ b/config/crd/patches/cainjection_in_serviceresolvers.yaml @@ -0,0 +1,8 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +# CRD conversion requires k8s 1.13 or later. +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) + name: serviceresolvers.consul.hashicorp.com diff --git a/config/crd/patches/cainjection_in_servicerouters.yaml b/config/crd/patches/cainjection_in_servicerouters.yaml new file mode 100644 index 0000000000..798e76fd2a --- /dev/null +++ b/config/crd/patches/cainjection_in_servicerouters.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: servicerouters.consul.hashicorp.com diff --git a/config/crd/patches/cainjection_in_servicesplitters.yaml b/config/crd/patches/cainjection_in_servicesplitters.yaml new file mode 100644 index 0000000000..288ca2cf23 --- /dev/null +++ b/config/crd/patches/cainjection_in_servicesplitters.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: servicesplitters.consul.hashicorp.com diff --git a/config/crd/patches/webhook_in_proxydefaults.yaml b/config/crd/patches/webhook_in_proxydefaults.yaml new file mode 100644 index 0000000000..4d2d4bfff1 --- /dev/null +++ b/config/crd/patches/webhook_in_proxydefaults.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: proxydefaults.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/crd/patches/webhook_in_servicedefaults.yaml b/config/crd/patches/webhook_in_servicedefaults.yaml new file mode 100644 index 0000000000..774953f50f --- /dev/null +++ b/config/crd/patches/webhook_in_servicedefaults.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: servicedefaults.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/crd/patches/webhook_in_serviceintentions.yaml b/config/crd/patches/webhook_in_serviceintentions.yaml new file mode 100644 index 0000000000..77fcdb48ff --- /dev/null +++ b/config/crd/patches/webhook_in_serviceintentions.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: serviceintentions.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/crd/patches/webhook_in_serviceresolvers.yaml b/config/crd/patches/webhook_in_serviceresolvers.yaml new file mode 100644 index 0000000000..b9f9b42a21 --- /dev/null +++ b/config/crd/patches/webhook_in_serviceresolvers.yaml @@ -0,0 +1,17 @@ +# The following patch enables conversion webhook for CRD +# CRD conversion requires k8s 1.13 or later. +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: serviceresolvers.consul.hashicorp.com +spec: + conversion: + strategy: Webhook + webhookClientConfig: + # this is "\n" used as a placeholder, otherwise it will be rejected by the apiserver for being blank, + # but we're going to set it later using the cert-manager (or potentially a patch if not using cert-manager) + caBundle: Cg== + service: + namespace: system + name: webhook-service + path: /convert diff --git a/config/crd/patches/webhook_in_servicerouters.yaml b/config/crd/patches/webhook_in_servicerouters.yaml new file mode 100644 index 0000000000..e4856434c8 --- /dev/null +++ b/config/crd/patches/webhook_in_servicerouters.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: servicerouters.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/crd/patches/webhook_in_servicesplitters.yaml b/config/crd/patches/webhook_in_servicesplitters.yaml new file mode 100644 index 0000000000..e8f7c1371f --- /dev/null +++ b/config/crd/patches/webhook_in_servicesplitters.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: servicesplitters.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/default/kustomization.yaml b/config/default/kustomization.yaml new file mode 100644 index 0000000000..df978482e5 --- /dev/null +++ b/config/default/kustomization.yaml @@ -0,0 +1,74 @@ +# Adds namespace to all resources. +namespace: default + +# Value of this field is prepended to the +# names of all resources, e.g. a deployment named +# "wordpress" becomes "alices-wordpress". +# Note that it should also match with the prefix (text before '-') of the namespace +# field above. +namePrefix: consul-controller- + +# Labels to add to all resources and selectors. +#commonLabels: +# someName: someValue + +# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in +# crd/kustomization.yaml +# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. +# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. +#- ../prometheus + + # Protect the /metrics endpoint by putting it behind auth. + # If you want your controller-manager to expose the /metrics + # endpoint w/o any authn/z, please comment the following line. + +# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in +# crd/kustomization.yaml + +# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. +# Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. +# 'CERTMANAGER' needs to be enabled to use ca injection +patchesStrategicMerge: +- manager_auth_proxy_patch.yaml +- manager_webhook_patch.yaml +- webhookcainjection_patch.yaml + +# the following config is for teaching kustomize how to do var substitution +# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. +vars: +- fieldref: + fieldPath: metadata.namespace + name: CERTIFICATE_NAMESPACE + objref: + group: cert-manager.io + kind: Certificate + name: serving-cert + version: v1alpha2 +- fieldref: {} + name: CERTIFICATE_NAME + objref: + group: cert-manager.io + kind: Certificate + name: serving-cert + version: v1alpha2 +- fieldref: + fieldPath: metadata.namespace + name: SERVICE_NAMESPACE + objref: + kind: Service + name: webhook-service + version: v1 +- fieldref: {} + name: SERVICE_NAME + objref: + kind: Service + name: webhook-service + version: v1 +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- ../crd +- ../rbac +- ../manager +- ../webhook +- ../certmanager diff --git a/config/default/manager_auth_proxy_patch.yaml b/config/default/manager_auth_proxy_patch.yaml new file mode 100644 index 0000000000..77e743d1c1 --- /dev/null +++ b/config/default/manager_auth_proxy_patch.yaml @@ -0,0 +1,25 @@ +# This patch inject a sidecar container which is a HTTP proxy for the +# controller manager, it performs RBAC authorization against the Kubernetes API using SubjectAccessReviews. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: controller-manager + namespace: system +spec: + template: + spec: + containers: + - name: kube-rbac-proxy + image: gcr.io/kubebuilder/kube-rbac-proxy:v0.5.0 + args: + - "--secure-listen-address=0.0.0.0:8443" + - "--upstream=http://127.0.0.1:8080/" + - "--logtostderr=true" + - "--v=10" + ports: + - containerPort: 8443 + name: https + - name: manager + args: + - "--metrics-addr=127.0.0.1:8080" + - "--enable-leader-election" diff --git a/config/default/manager_webhook_patch.yaml b/config/default/manager_webhook_patch.yaml new file mode 100644 index 0000000000..738de350b7 --- /dev/null +++ b/config/default/manager_webhook_patch.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: controller-manager + namespace: system +spec: + template: + spec: + containers: + - name: manager + ports: + - containerPort: 9443 + name: webhook-server + protocol: TCP + volumeMounts: + - mountPath: /tmp/k8s-webhook-server/serving-certs + name: cert + readOnly: true + volumes: + - name: cert + secret: + defaultMode: 420 + secretName: webhook-server-cert diff --git a/config/default/webhookcainjection_patch.yaml b/config/default/webhookcainjection_patch.yaml new file mode 100644 index 0000000000..79c5b20a63 --- /dev/null +++ b/config/default/webhookcainjection_patch.yaml @@ -0,0 +1,8 @@ +# This patch add annotation to admission webhook config and +# the variables $(CERTIFICATE_NAMESPACE) and $(CERTIFICATE_NAME) will be substituted by kustomize. +apiVersion: admissionregistration.k8s.io/v1beta1 +kind: MutatingWebhookConfiguration +metadata: + name: mutating-webhook-configuration + annotations: + cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml new file mode 100644 index 0000000000..957b423a0a --- /dev/null +++ b/config/manager/kustomization.yaml @@ -0,0 +1,9 @@ +resources: +- manager.yaml +# todo: this was auto-generated +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +images: +- name: controller + newName: ashwinvenkatesh/consul-k8s + newTag: latest diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml new file mode 100644 index 0000000000..b086533218 --- /dev/null +++ b/config/manager/manager.yaml @@ -0,0 +1,48 @@ +apiVersion: v1 +kind: Namespace +metadata: + labels: + control-plane: controller-manager + name: system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: controller-manager + namespace: system + labels: + control-plane: controller-manager +spec: + selector: + matchLabels: + control-plane: controller-manager + replicas: 1 + template: + metadata: + labels: + control-plane: controller-manager + spec: + containers: + - name: manager + env: + - name: HOST_IP + valueFrom: + fieldRef: + fieldPath: status.hostIP + - name: CONSUL_HTTP_ADDR + value: http://$(HOST_IP):8500 + command: + - consul-k8s + - controller + args: + - --enable-leader-election + image: controller:latest + resources: + limits: + cpu: 100m + memory: 30Mi + requests: + cpu: 100m + memory: 20Mi + + terminationGracePeriodSeconds: 10 diff --git a/config/rbac/auth_proxy_client_clusterrole.yaml b/config/rbac/auth_proxy_client_clusterrole.yaml new file mode 100644 index 0000000000..7d62534c5f --- /dev/null +++ b/config/rbac/auth_proxy_client_clusterrole.yaml @@ -0,0 +1,7 @@ +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRole +metadata: + name: metrics-reader +rules: +- nonResourceURLs: ["/metrics"] + verbs: ["get"] diff --git a/config/rbac/auth_proxy_role.yaml b/config/rbac/auth_proxy_role.yaml new file mode 100644 index 0000000000..618f5e4177 --- /dev/null +++ b/config/rbac/auth_proxy_role.yaml @@ -0,0 +1,13 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: proxy-role +rules: +- apiGroups: ["authentication.k8s.io"] + resources: + - tokenreviews + verbs: ["create"] +- apiGroups: ["authorization.k8s.io"] + resources: + - subjectaccessreviews + verbs: ["create"] diff --git a/config/rbac/auth_proxy_role_binding.yaml b/config/rbac/auth_proxy_role_binding.yaml new file mode 100644 index 0000000000..48ed1e4b85 --- /dev/null +++ b/config/rbac/auth_proxy_role_binding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: proxy-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: proxy-role +subjects: +- kind: ServiceAccount + name: default + namespace: system diff --git a/config/rbac/auth_proxy_service.yaml b/config/rbac/auth_proxy_service.yaml new file mode 100644 index 0000000000..6cf656be14 --- /dev/null +++ b/config/rbac/auth_proxy_service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + control-plane: controller-manager + name: controller-manager-metrics-service + namespace: system +spec: + ports: + - name: https + port: 8443 + targetPort: https + selector: + control-plane: controller-manager diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml new file mode 100644 index 0000000000..66c28338fe --- /dev/null +++ b/config/rbac/kustomization.yaml @@ -0,0 +1,12 @@ +resources: +- role.yaml +- role_binding.yaml +- leader_election_role.yaml +- leader_election_role_binding.yaml +# Comment the following 4 lines if you want to disable +# the auth proxy (https://github.com/brancz/kube-rbac-proxy) +# which protects your /metrics endpoint. +- auth_proxy_service.yaml +- auth_proxy_role.yaml +- auth_proxy_role_binding.yaml +- auth_proxy_client_clusterrole.yaml diff --git a/config/rbac/leader_election_role.yaml b/config/rbac/leader_election_role.yaml new file mode 100644 index 0000000000..7dc16c420e --- /dev/null +++ b/config/rbac/leader_election_role.yaml @@ -0,0 +1,33 @@ +# permissions to do leader election. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: leader-election-role +rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - "" + resources: + - configmaps/status + verbs: + - get + - update + - patch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch diff --git a/config/rbac/leader_election_role_binding.yaml b/config/rbac/leader_election_role_binding.yaml new file mode 100644 index 0000000000..eed16906f4 --- /dev/null +++ b/config/rbac/leader_election_role_binding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: leader-election-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: leader-election-role +subjects: +- kind: ServiceAccount + name: default + namespace: system diff --git a/config/rbac/proxydefaults_editor_role.yaml b/config/rbac/proxydefaults_editor_role.yaml new file mode 100644 index 0000000000..42afc1a916 --- /dev/null +++ b/config/rbac/proxydefaults_editor_role.yaml @@ -0,0 +1,24 @@ +# permissions for end users to edit proxydefaults. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: proxydefaults-editor-role +rules: +- apiGroups: + - consul.hashicorp.com + resources: + - proxydefaults + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - proxydefaults/status + verbs: + - get diff --git a/config/rbac/proxydefaults_viewer_role.yaml b/config/rbac/proxydefaults_viewer_role.yaml new file mode 100644 index 0000000000..b16fda3894 --- /dev/null +++ b/config/rbac/proxydefaults_viewer_role.yaml @@ -0,0 +1,20 @@ +# permissions for end users to view proxydefaults. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: proxydefaults-viewer-role +rules: +- apiGroups: + - consul.hashicorp.com + resources: + - proxydefaults + verbs: + - get + - list + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - proxydefaults/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml new file mode 100644 index 0000000000..7578995177 --- /dev/null +++ b/config/rbac/role.yaml @@ -0,0 +1,128 @@ + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + creationTimestamp: null + name: manager-role +rules: +- apiGroups: + - consul.hashicorp.com + resources: + - proxydefaults + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - proxydefaults/status + verbs: + - get + - patch + - update +- apiGroups: + - consul.hashicorp.com + resources: + - servicedefaults + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - servicedefaults/status + verbs: + - get + - patch + - update +- apiGroups: + - consul.hashicorp.com + resources: + - serviceintentions + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - serviceintentions/status + verbs: + - get + - patch + - update +- apiGroups: + - consul.hashicorp.com + resources: + - serviceresolvers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - serviceresolvers/status + verbs: + - get + - patch + - update +- apiGroups: + - consul.hashicorp.com + resources: + - servicerouters + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - servicerouters/status + verbs: + - get + - patch + - update +- apiGroups: + - consul.hashicorp.com + resources: + - servicesplitters + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - servicesplitters/status + verbs: + - get + - patch + - update diff --git a/config/rbac/role_binding.yaml b/config/rbac/role_binding.yaml new file mode 100644 index 0000000000..8f2658702c --- /dev/null +++ b/config/rbac/role_binding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: manager-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: manager-role +subjects: +- kind: ServiceAccount + name: default + namespace: system diff --git a/config/rbac/servicedefaults_editor_role.yaml b/config/rbac/servicedefaults_editor_role.yaml new file mode 100644 index 0000000000..f5a7d27a26 --- /dev/null +++ b/config/rbac/servicedefaults_editor_role.yaml @@ -0,0 +1,24 @@ +# permissions for end users to edit servicedefaults. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: servicedefaults-editor-role +rules: +- apiGroups: + - consul.hashicorp.com + resources: + - servicedefaults + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - servicedefaults/status + verbs: + - get diff --git a/config/rbac/servicedefaults_viewer_role.yaml b/config/rbac/servicedefaults_viewer_role.yaml new file mode 100644 index 0000000000..ac9ccc9b3b --- /dev/null +++ b/config/rbac/servicedefaults_viewer_role.yaml @@ -0,0 +1,20 @@ +# permissions for end users to view servicedefaults. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: servicedefaults-viewer-role +rules: +- apiGroups: + - consul.hashicorp.com + resources: + - servicedefaults + verbs: + - get + - list + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - servicedefaults/status + verbs: + - get diff --git a/config/rbac/serviceintentions_editor_role.yaml b/config/rbac/serviceintentions_editor_role.yaml new file mode 100644 index 0000000000..83a0437d7d --- /dev/null +++ b/config/rbac/serviceintentions_editor_role.yaml @@ -0,0 +1,24 @@ +# permissions for end users to edit serviceintentions. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: serviceintentions-editor-role +rules: +- apiGroups: + - consul.hashicorp.com + resources: + - serviceintentions + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - serviceintentions/status + verbs: + - get diff --git a/config/rbac/serviceintentions_viewer_role.yaml b/config/rbac/serviceintentions_viewer_role.yaml new file mode 100644 index 0000000000..6a5f41c960 --- /dev/null +++ b/config/rbac/serviceintentions_viewer_role.yaml @@ -0,0 +1,20 @@ +# permissions for end users to view serviceintentions. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: serviceintentions-viewer-role +rules: +- apiGroups: + - consul.hashicorp.com + resources: + - serviceintentions + verbs: + - get + - list + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - serviceintentions/status + verbs: + - get diff --git a/config/rbac/serviceresolver_editor_role.yaml b/config/rbac/serviceresolver_editor_role.yaml new file mode 100644 index 0000000000..5baff84934 --- /dev/null +++ b/config/rbac/serviceresolver_editor_role.yaml @@ -0,0 +1,24 @@ +# permissions for end users to edit serviceresolvers. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: serviceresolver-editor-role +rules: +- apiGroups: + - consul.hashicorp.com + resources: + - serviceresolvers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - serviceresolvers/status + verbs: + - get diff --git a/config/rbac/serviceresolver_viewer_role.yaml b/config/rbac/serviceresolver_viewer_role.yaml new file mode 100644 index 0000000000..ca990258fb --- /dev/null +++ b/config/rbac/serviceresolver_viewer_role.yaml @@ -0,0 +1,20 @@ +# permissions for end users to view serviceresolvers. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: serviceresolver-viewer-role +rules: +- apiGroups: + - consul.hashicorp.com + resources: + - serviceresolvers + verbs: + - get + - list + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - serviceresolvers/status + verbs: + - get diff --git a/config/rbac/servicerouter_editor_role.yaml b/config/rbac/servicerouter_editor_role.yaml new file mode 100644 index 0000000000..c66e6c1ddd --- /dev/null +++ b/config/rbac/servicerouter_editor_role.yaml @@ -0,0 +1,24 @@ +# permissions for end users to edit servicerouters. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: servicerouter-editor-role +rules: +- apiGroups: + - consul.hashicorp.com + resources: + - servicerouters + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - servicerouters/status + verbs: + - get diff --git a/config/rbac/servicerouter_viewer_role.yaml b/config/rbac/servicerouter_viewer_role.yaml new file mode 100644 index 0000000000..c2cb68dfe8 --- /dev/null +++ b/config/rbac/servicerouter_viewer_role.yaml @@ -0,0 +1,20 @@ +# permissions for end users to view servicerouters. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: servicerouter-viewer-role +rules: +- apiGroups: + - consul.hashicorp.com + resources: + - servicerouters + verbs: + - get + - list + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - servicerouters/status + verbs: + - get diff --git a/config/rbac/servicesplitter_editor_role.yaml b/config/rbac/servicesplitter_editor_role.yaml new file mode 100644 index 0000000000..ec08f8a114 --- /dev/null +++ b/config/rbac/servicesplitter_editor_role.yaml @@ -0,0 +1,24 @@ +# permissions for end users to edit servicesplitters. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: servicesplitter-editor-role +rules: +- apiGroups: + - consul.hashicorp.com + resources: + - servicesplitters + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - servicesplitters/status + verbs: + - get diff --git a/config/rbac/servicesplitter_viewer_role.yaml b/config/rbac/servicesplitter_viewer_role.yaml new file mode 100644 index 0000000000..6e9458243f --- /dev/null +++ b/config/rbac/servicesplitter_viewer_role.yaml @@ -0,0 +1,20 @@ +# permissions for end users to view servicesplitters. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: servicesplitter-viewer-role +rules: +- apiGroups: + - consul.hashicorp.com + resources: + - servicesplitters + verbs: + - get + - list + - watch +- apiGroups: + - consul.hashicorp.com + resources: + - servicesplitters/status + verbs: + - get diff --git a/config/samples/consul_v1alpha1_proxydefaults.yaml b/config/samples/consul_v1alpha1_proxydefaults.yaml new file mode 100644 index 0000000000..2a87f89ed7 --- /dev/null +++ b/config/samples/consul_v1alpha1_proxydefaults.yaml @@ -0,0 +1,7 @@ +apiVersion: consul.hashicorp.com/v1alpha1 +kind: ProxyDefaults +metadata: + name: proxydefaults-sample +spec: + # Add fields here + foo: bar diff --git a/config/samples/consul_v1alpha1_servicedefaults.yaml b/config/samples/consul_v1alpha1_servicedefaults.yaml new file mode 100644 index 0000000000..f395256b81 --- /dev/null +++ b/config/samples/consul_v1alpha1_servicedefaults.yaml @@ -0,0 +1,6 @@ +apiVersion: consul.hashicorp.com/v1alpha1 +kind: ServiceDefaults +metadata: + name: servicedefaults-sample +spec: + protocol: "http" diff --git a/config/samples/consul_v1alpha1_serviceintentions.yaml b/config/samples/consul_v1alpha1_serviceintentions.yaml new file mode 100644 index 0000000000..1c6dc83597 --- /dev/null +++ b/config/samples/consul_v1alpha1_serviceintentions.yaml @@ -0,0 +1,7 @@ +apiVersion: consul.hashicorp.com/v1alpha1 +kind: ServiceIntentions +metadata: + name: serviceintentions-sample +spec: + # Add fields here + foo: bar diff --git a/config/samples/consul_v1alpha1_serviceresolver.yaml b/config/samples/consul_v1alpha1_serviceresolver.yaml new file mode 100644 index 0000000000..f1a2f29c8b --- /dev/null +++ b/config/samples/consul_v1alpha1_serviceresolver.yaml @@ -0,0 +1,7 @@ +apiVersion: consul.hashicorp.com/v1alpha1 +kind: ServiceResolver +metadata: + name: serviceresolver-sample +spec: + # Add fields here + foo: bar diff --git a/config/samples/consul_v1alpha1_servicerouter.yaml b/config/samples/consul_v1alpha1_servicerouter.yaml new file mode 100644 index 0000000000..df597031a9 --- /dev/null +++ b/config/samples/consul_v1alpha1_servicerouter.yaml @@ -0,0 +1,11 @@ +apiVersion: consul.hashicorp.com/v1alpha1 +kind: ServiceRouter +metadata: + name: servicerouter-sample +spec: + routes: + - match: + http: + pathPrefix: "/admin" + destination: + service: admin diff --git a/config/samples/consul_v1alpha1_servicesplitter.yaml b/config/samples/consul_v1alpha1_servicesplitter.yaml new file mode 100644 index 0000000000..a432bd8117 --- /dev/null +++ b/config/samples/consul_v1alpha1_servicesplitter.yaml @@ -0,0 +1,7 @@ +apiVersion: consul.hashicorp.com/v1alpha1 +kind: ServiceSplitter +metadata: + name: servicesplitter-sample +spec: + # Add fields here + foo: bar diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml new file mode 100644 index 0000000000..c6cf924560 --- /dev/null +++ b/config/samples/kustomization.yaml @@ -0,0 +1,3 @@ +## This file is auto-generated, do not modify ## +resources: +- consul_v1alpha1_servicedefaults.yaml diff --git a/config/webhook/kustomization.yaml b/config/webhook/kustomization.yaml new file mode 100644 index 0000000000..9cf26134e4 --- /dev/null +++ b/config/webhook/kustomization.yaml @@ -0,0 +1,6 @@ +resources: +- manifests.yaml +- service.yaml + +configurations: +- kustomizeconfig.yaml diff --git a/config/webhook/kustomizeconfig.yaml b/config/webhook/kustomizeconfig.yaml new file mode 100644 index 0000000000..25e21e3c96 --- /dev/null +++ b/config/webhook/kustomizeconfig.yaml @@ -0,0 +1,25 @@ +# the following config is for teaching kustomize where to look at when substituting vars. +# It requires kustomize v2.1.0 or newer to work properly. +nameReference: +- kind: Service + version: v1 + fieldSpecs: + - kind: MutatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/name + - kind: ValidatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/name + +namespace: +- kind: MutatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/namespace + create: true +- kind: ValidatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/namespace + create: true + +varReference: +- path: metadata/annotations diff --git a/config/webhook/manifests.v1beta1.yaml b/config/webhook/manifests.v1beta1.yaml new file mode 100644 index 0000000000..a60c818617 --- /dev/null +++ b/config/webhook/manifests.v1beta1.yaml @@ -0,0 +1,122 @@ + +--- +apiVersion: admissionregistration.k8s.io/v1beta1 +kind: MutatingWebhookConfiguration +metadata: + creationTimestamp: null + name: mutating-webhook-configuration +webhooks: +- clientConfig: + caBundle: Cg== + service: + name: webhook-service + namespace: system + path: /mutate-v1alpha1-proxydefaults + failurePolicy: Fail + name: mutate-proxydefaults.consul.hashicorp.com + rules: + - apiGroups: + - consul.hashicorp.com + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - proxydefaults + sideEffects: None +- clientConfig: + caBundle: Cg== + service: + name: webhook-service + namespace: system + path: /mutate-v1alpha1-servicedefaults + failurePolicy: Fail + name: mutate-servicedefaults.consul.hashicorp.com + rules: + - apiGroups: + - consul.hashicorp.com + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - servicedefaults + sideEffects: None +- clientConfig: + caBundle: Cg== + service: + name: webhook-service + namespace: system + path: /mutate-v1alpha1-serviceintentions + failurePolicy: Fail + name: mutate-serviceintentions.consul.hashicorp.com + rules: + - apiGroups: + - consul.hashicorp.com + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - serviceintentions + sideEffects: None +- clientConfig: + caBundle: Cg== + service: + name: webhook-service + namespace: system + path: /mutate-v1alpha1-serviceresolver + failurePolicy: Fail + name: mutate-serviceresolver.consul.hashicorp.com + rules: + - apiGroups: + - consul.hashicorp.com + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - serviceresolvers + sideEffects: None +- clientConfig: + caBundle: Cg== + service: + name: webhook-service + namespace: system + path: /mutate-v1alpha1-servicerouter + failurePolicy: Fail + name: mutate-servicerouter.consul.hashicorp.com + rules: + - apiGroups: + - consul.hashicorp.com + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - servicerouters + sideEffects: None +- clientConfig: + caBundle: Cg== + service: + name: webhook-service + namespace: system + path: /mutate-v1alpha1-servicesplitter + failurePolicy: Fail + name: mutate-servicesplitter.consul.hashicorp.com + rules: + - apiGroups: + - consul.hashicorp.com + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - servicesplitters + sideEffects: None diff --git a/config/webhook/service.yaml b/config/webhook/service.yaml new file mode 100644 index 0000000000..31e0f82959 --- /dev/null +++ b/config/webhook/service.yaml @@ -0,0 +1,12 @@ + +apiVersion: v1 +kind: Service +metadata: + name: webhook-service + namespace: system +spec: + ports: + - port: 443 + targetPort: 9443 + selector: + control-plane: controller-manager diff --git a/connect-inject/handler.go b/connect-inject/handler.go index ccc1c162df..f4bb303f03 100644 --- a/connect-inject/handler.go +++ b/connect-inject/handler.go @@ -9,6 +9,7 @@ import ( "strconv" "github.com/deckarep/golang-set" + "github.com/hashicorp/consul-k8s/namespaces" "github.com/hashicorp/consul/api" "github.com/hashicorp/go-hclog" "github.com/mattbaird/jsonpatch" @@ -370,8 +371,7 @@ func (h *Handler) Mutate(req *v1beta1.AdmissionRequest) *v1beta1.AdmissionRespon // all patches are created to guarantee no errors were encountered in // that process before modifying the Consul cluster. if h.EnableNamespaces { - // Check if the namespace exists. If not, create it. - if err := h.checkAndCreateNamespace(h.consulNamespace(req.Namespace)); err != nil { + if _, err := namespaces.EnsureExists(h.ConsulClient, h.consulNamespace(req.Namespace), h.CrossNamespaceACLPolicy); err != nil { h.Log.Error("Error checking or creating namespace", "err", err, "Namespace", h.consulNamespace(req.Namespace), "Request Name", req.Name) return &v1beta1.AdmissionResponse{ @@ -491,52 +491,7 @@ func (h *Handler) defaultAnnotations(pod *corev1.Pod, patches *[]jsonpatch.JsonP // registered in based on the namespace options. It returns an // empty string if namespaces aren't enabled. func (h *Handler) consulNamespace(ns string) string { - if !h.EnableNamespaces { - return "" - } - - // Mirroring takes precedence - if h.EnableK8SNSMirroring { - return fmt.Sprintf("%s%s", h.K8SNSMirroringPrefix, ns) - } else { - return h.ConsulDestinationNamespace - } -} - -func (h *Handler) checkAndCreateNamespace(ns string) error { - // Check if the Consul namespace exists - namespaceInfo, _, err := h.ConsulClient.Namespaces().Read(ns, nil) - if err != nil { - return err - } - - // If not, create it - if namespaceInfo == nil { - var aclConfig api.NamespaceACLConfig - if h.CrossNamespaceACLPolicy != "" { - // Create the ACLs config for the cross-Consul-namespace - // default policy that needs to be attached - aclConfig = api.NamespaceACLConfig{ - PolicyDefaults: []api.ACLLink{ - {Name: h.CrossNamespaceACLPolicy}, - }, - } - } - - consulNamespace := api.Namespace{ - Name: ns, - Description: "Auto-generated by a Connect Injector", - ACLs: &aclConfig, - Meta: map[string]string{"external-source": "kubernetes"}, - } - - _, _, err = h.ConsulClient.Namespaces().Create(&consulNamespace, nil) - if err != nil { - return err - } - } - - return nil + return namespaces.ConsulNamespace(ns, h.EnableNamespaces, h.ConsulDestinationNamespace, h.EnableK8SNSMirroring, h.K8SNSMirroringPrefix) } func portValue(pod *corev1.Pod, value string) (int32, error) { diff --git a/connect-inject/handler_ent_test.go b/connect-inject/handler_ent_test.go index 11fabf90bb..3779d21edf 100644 --- a/connect-inject/handler_ent_test.go +++ b/connect-inject/handler_ent_test.go @@ -225,7 +225,7 @@ func TestHandler_MutateWithNamespaces(t *testing.T) { // Check created namespace properties if ns != "default" { - require.Equalf("Auto-generated by a Connect Injector", actNamespace.Description, + require.Equalf("Auto-generated by consul-k8s", actNamespace.Description, "wrong namespace description for namespace %s", ns) require.Containsf(actNamespace.Meta, "external-source", "namespace %s does not contain external-source metadata key", ns) @@ -420,7 +420,6 @@ func TestHandler_MutateWithNamespaces_ACLs(t *testing.T) { a, err := testutil.NewTestServerConfigT(t, func(c *testutil.TestServerConfig) { c.ACL.Enabled = true }) - require.NoError(t, err) defer a.Stop() // Set up a client for bootstrapping @@ -489,7 +488,7 @@ func TestHandler_MutateWithNamespaces_ACLs(t *testing.T) { // Check created namespace properties if ns != "default" { - require.Equalf(t, "Auto-generated by a Connect Injector", actNamespace.Description, + require.Equalf(t, "Auto-generated by consul-k8s", actNamespace.Description, "wrong namespace description for namespace %s", ns) require.Containsf(t, actNamespace.Meta, "external-source", "namespace %s does not contain external-source metadata key", ns) diff --git a/controller/configentry_controller.go b/controller/configentry_controller.go new file mode 100644 index 0000000000..fed1eda278 --- /dev/null +++ b/controller/configentry_controller.go @@ -0,0 +1,275 @@ +package controller + +import ( + "context" + "fmt" + "strings" + + "github.com/go-logr/logr" + "github.com/hashicorp/consul-k8s/api/common" + "github.com/hashicorp/consul-k8s/namespaces" + capi "github.com/hashicorp/consul/api" + corev1 "k8s.io/api/core/v1" + k8serr "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + FinalizerName = "finalizers.consul.hashicorp.com" + ConsulAgentError = "ConsulAgentError" + ExternallyManagedConfigError = "ExternallyManagedConfigError" +) + +// Controller is implemented by CRD-specific controllers. It is used by +// ConfigEntryController to abstract CRD-specific controllers. +type Controller interface { + // Update updates the state of the whole object. + Update(context.Context, runtime.Object, ...client.UpdateOption) error + // UpdateStatus updates the state of just the object's status. + UpdateStatus(context.Context, runtime.Object, ...client.UpdateOption) error + // Get retrieves an obj for the given object key from the Kubernetes Cluster. + // obj must be a struct pointer so that obj can be updated with the response + // returned by the Server. + Get(ctx context.Context, key client.ObjectKey, obj runtime.Object) error + // Logger returns a logger with values added for the specific controller + // and request name. + Logger(types.NamespacedName) logr.Logger +} + +// ConfigEntryController is a generic controller that is used to reconcile +// all config entry types, e.g. ServiceDefaults, ServiceResolver, etc, since +// they share the same reconcile behaviour. +type ConfigEntryController struct { + ConsulClient *capi.Client + + // DatacenterName indicates the Consul Datacenter name the controller is + // operating in. Adds this value as metadata on managed resources. + DatacenterName string + + // EnableConsulNamespaces indicates that a user is running Consul Enterprise + // with version 1.7+ which supports namespaces. + EnableConsulNamespaces bool + + // ConsulDestinationNamespace is the name of the Consul namespace to create + // all config entries in. If EnableNSMirroring is true this is ignored. + ConsulDestinationNamespace string + + // EnableNSMirroring causes Consul namespaces to be created to match the + // k8s namespace of any config entry custom resource. Config entries will + // be created in the matching Consul namespace. + EnableNSMirroring bool + + // NSMirroringPrefix is an optional prefix that can be added to the Consul + // namespaces created while mirroring. For example, if it is set to "k8s-", + // then the k8s `default` namespace will be mirrored in Consul's + // `k8s-default` namespace. + NSMirroringPrefix string + + // CrossNSACLPolicy is the name of the ACL policy to attach to + // any created Consul namespaces to allow cross namespace service discovery. + // Only necessary if ACLs are enabled. + CrossNSACLPolicy string +} + +// ReconcileEntry reconciles an update to a resource. CRD-specific controller's +// call this function because it handles reconciliation of config entries +// generically. +// CRD-specific controller should pass themselves in as updater since we +// need to call back into their own update methods to ensure they update their +// internal state. +func (r *ConfigEntryController) ReconcileEntry( + crdCtrl Controller, + req ctrl.Request, + configEntry common.ConfigEntryResource) (ctrl.Result, error) { + + ctx := context.Background() + logger := crdCtrl.Logger(req.NamespacedName) + + err := crdCtrl.Get(ctx, req.NamespacedName, configEntry) + if k8serr.IsNotFound(err) { + return ctrl.Result{}, client.IgnoreNotFound(err) + } else if err != nil { + logger.Error(err, "retrieving resource") + return ctrl.Result{}, err + } + + if configEntry.GetObjectMeta().DeletionTimestamp.IsZero() { + // The object is not being deleted, so if it does not have our finalizer, + // then let's add the finalizer and update the object. This is equivalent + // registering our finalizer. + if !containsString(configEntry.GetObjectMeta().Finalizers, FinalizerName) { + configEntry.AddFinalizer(FinalizerName) + if err := r.syncUnknown(ctx, crdCtrl, configEntry); err != nil { + return ctrl.Result{}, err + } + } + } else { + // The object is being deleted + if containsString(configEntry.GetObjectMeta().Finalizers, FinalizerName) { + logger.Info("deletion event") + // Check to see if consul has config entry with the same name + entry, _, err := r.ConsulClient.ConfigEntries().Get(configEntry.ConsulKind(), configEntry.ConsulName(), &capi.QueryOptions{ + Namespace: r.consulNamespace(configEntry.ConsulMirroringNS(), configEntry.ConsulGlobalResource()), + }) + + // Ignore the error where the config entry isn't found in Consul. + // It is indicative of desired state. + if err != nil && !isNotFoundErr(err) { + return ctrl.Result{}, fmt.Errorf("getting config entry from consul: %w", err) + } else if err == nil { + // Only delete the resource from Consul if it is owned by our datacenter. + if entry.GetMeta()[common.DatacenterKey] == r.DatacenterName { + _, err := r.ConsulClient.ConfigEntries().Delete(configEntry.ConsulKind(), configEntry.ConsulName(), &capi.WriteOptions{ + Namespace: r.consulNamespace(configEntry.ConsulMirroringNS(), configEntry.ConsulGlobalResource()), + }) + if err != nil { + return ctrl.Result{}, fmt.Errorf("deleting config entry from consul: %w", err) + } + logger.Info("deletion from Consul successful") + } else { + logger.Info("config entry in Consul was created in another datacenter - skipping delete from Consul", "external-datacenter", entry.GetMeta()[common.DatacenterKey]) + } + } + // remove our finalizer from the list and update it. + configEntry.RemoveFinalizer(FinalizerName) + if err := crdCtrl.Update(ctx, configEntry); err != nil { + return ctrl.Result{}, err + } + logger.Info("finalizer removed") + } + + // Stop reconciliation as the item is being deleted + return ctrl.Result{}, nil + } + + // Check to see if consul has config entry with the same name + entry, _, err := r.ConsulClient.ConfigEntries().Get(configEntry.ConsulKind(), configEntry.ConsulName(), &capi.QueryOptions{ + Namespace: r.consulNamespace(configEntry.ConsulMirroringNS(), configEntry.ConsulGlobalResource()), + }) + // If a config entry with this name does not exist + if isNotFoundErr(err) { + logger.Info("config entry not found in consul") + + // If Consul namespaces are enabled we may need to create the + // destination consul namespace first. + if r.EnableConsulNamespaces { + consulNS := r.consulNamespace(configEntry.ConsulMirroringNS(), configEntry.ConsulGlobalResource()) + created, err := namespaces.EnsureExists(r.ConsulClient, consulNS, r.CrossNSACLPolicy) + if err != nil { + return r.syncFailed(ctx, logger, crdCtrl, configEntry, ConsulAgentError, + fmt.Errorf("creating consul namespace %q: %w", consulNS, err)) + } + if created { + logger.Info("consul namespace created", "ns", consulNS) + } + } + + // Create the config entry + _, writeMeta, err := r.ConsulClient.ConfigEntries().Set(configEntry.ToConsul(r.DatacenterName), &capi.WriteOptions{ + Namespace: r.consulNamespace(configEntry.ConsulMirroringNS(), configEntry.ConsulGlobalResource()), + }) + if err != nil { + return r.syncFailed(ctx, logger, crdCtrl, configEntry, ConsulAgentError, + fmt.Errorf("writing config entry to consul: %w", err)) + } + logger.Info("config entry created", "request-time", writeMeta.RequestTime) + return r.syncSuccessful(ctx, crdCtrl, configEntry) + } + + // If there is an error when trying to get the config entry from the api server, + // fail the reconcile. + if err != nil { + return r.syncFailed(ctx, logger, crdCtrl, configEntry, ConsulAgentError, err) + } + + // Check if the config entry is managed by our datacenter. + // Do not process resource if the entry was not created within our datacenter + // as it was created in a different cluster which will be managing that config entry. + if entry.GetMeta()[common.DatacenterKey] != r.DatacenterName { + return r.syncFailed(ctx, logger, crdCtrl, configEntry, ExternallyManagedConfigError, fmt.Errorf("config entry managed in different datacenter: %q", entry.GetMeta()[common.DatacenterKey])) + } + + if !configEntry.MatchesConsul(entry) { + logger.Info("config entry does not match consul", "modify-index", entry.GetModifyIndex()) + _, writeMeta, err := r.ConsulClient.ConfigEntries().Set(configEntry.ToConsul(r.DatacenterName), &capi.WriteOptions{ + Namespace: r.consulNamespace(configEntry.ConsulMirroringNS(), configEntry.ConsulGlobalResource()), + }) + if err != nil { + return r.syncUnknownWithError(ctx, logger, crdCtrl, configEntry, ConsulAgentError, + fmt.Errorf("updating config entry in consul: %w", err)) + } + logger.Info("config entry updated", "request-time", writeMeta.RequestTime) + return r.syncSuccessful(ctx, crdCtrl, configEntry) + } else if configEntry.SyncedConditionStatus() != corev1.ConditionTrue { + return r.syncSuccessful(ctx, crdCtrl, configEntry) + } + + return ctrl.Result{}, nil +} + +func (r *ConfigEntryController) consulNamespace(namespace string, globalResource bool) string { + // Does not attempt to parse the namespace for global resources like ProxyDefaults or + // wildcard namespace destinations are they will not be prefixed and will remain "default"/"*". + if !globalResource && namespace != common.WildcardNamespace { + return namespaces.ConsulNamespace(namespace, r.EnableConsulNamespaces, r.ConsulDestinationNamespace, r.EnableNSMirroring, r.NSMirroringPrefix) + } + if r.EnableConsulNamespaces { + return namespace + } + return "" +} + +func (r *ConfigEntryController) syncFailed(ctx context.Context, logger logr.Logger, updater Controller, configEntry common.ConfigEntryResource, errType string, err error) (ctrl.Result, error) { + configEntry.SetSyncedCondition(corev1.ConditionFalse, errType, err.Error()) + if updateErr := updater.UpdateStatus(ctx, configEntry); updateErr != nil { + // Log the original error here because we are returning the updateErr. + // Otherwise the original error would be lost. + logger.Error(err, "sync failed") + return ctrl.Result{}, updateErr + } + return ctrl.Result{}, err +} + +func (r *ConfigEntryController) syncSuccessful(ctx context.Context, updater Controller, configEntry common.ConfigEntryResource) (ctrl.Result, error) { + configEntry.SetSyncedCondition(corev1.ConditionTrue, "", "") + return ctrl.Result{}, updater.UpdateStatus(ctx, configEntry) +} + +func (r *ConfigEntryController) syncUnknown(ctx context.Context, updater Controller, configEntry common.ConfigEntryResource) error { + configEntry.SetSyncedCondition(corev1.ConditionUnknown, "", "") + return updater.Update(ctx, configEntry) +} + +func (r *ConfigEntryController) syncUnknownWithError(ctx context.Context, + logger logr.Logger, + updater Controller, + configEntry common.ConfigEntryResource, + errType string, + err error) (ctrl.Result, error) { + + configEntry.SetSyncedCondition(corev1.ConditionUnknown, errType, err.Error()) + if updateErr := updater.UpdateStatus(ctx, configEntry); updateErr != nil { + // Log the original error here because we are returning the updateErr. + // Otherwise the original error would be lost. + logger.Error(err, "sync status unknown") + return ctrl.Result{}, updateErr + } + return ctrl.Result{}, err +} + +func isNotFoundErr(err error) bool { + return err != nil && strings.Contains(err.Error(), "404") +} + +// containsString returns true if s is in slice. +func containsString(slice []string, s string) bool { + for _, item := range slice { + if item == s { + return true + } + } + return false +} diff --git a/controller/configentry_controller_ent_test.go b/controller/configentry_controller_ent_test.go new file mode 100644 index 0000000000..be4be6c0aa --- /dev/null +++ b/controller/configentry_controller_ent_test.go @@ -0,0 +1,771 @@ +// +build enterprise + +package controller_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/go-logr/logr" + logrtest "github.com/go-logr/logr/testing" + "github.com/hashicorp/consul-k8s/api/common" + "github.com/hashicorp/consul-k8s/api/v1alpha1" + "github.com/hashicorp/consul-k8s/controller" + capi "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/sdk/testutil" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// NOTE: We're not testing each controller type here because that's done in +// the OSS tests and it would result in too many permutations. Instead +// we're only testing with the ServiceDefaults and ProxyDefaults controller which will exercise +// all the namespaces code for config entries that are namespaced and those that +// exist in the global namespace. + +func TestConfigEntryController_createsConfigEntry_consulNamespaces(tt *testing.T) { + tt.Parallel() + + cases := map[string]struct { + Mirror bool + MirrorPrefix string + SourceKubeNS string + DestConsulNS string + ExpConsulNS string + }{ + "SourceKubeNS=default, DestConsulNS=default": { + SourceKubeNS: "default", + DestConsulNS: "default", + ExpConsulNS: "default", + }, + "SourceKubeNS=kube, DestConsulNS=default": { + SourceKubeNS: "kube", + DestConsulNS: "default", + ExpConsulNS: "default", + }, + "SourceKubeNS=default, DestConsulNS=other": { + SourceKubeNS: "default", + DestConsulNS: "other", + ExpConsulNS: "other", + }, + "SourceKubeNS=kube, DestConsulNS=other": { + SourceKubeNS: "kube", + DestConsulNS: "other", + ExpConsulNS: "other", + }, + "SourceKubeNS=default, Mirror=true": { + SourceKubeNS: "default", + Mirror: true, + ExpConsulNS: "default", + }, + "SourceKubeNS=kube, Mirror=true": { + SourceKubeNS: "kube", + Mirror: true, + ExpConsulNS: "kube", + }, + "SourceKubeNS=default, Mirror=true, Prefix=prefix": { + SourceKubeNS: "default", + Mirror: true, + MirrorPrefix: "prefix-", + ExpConsulNS: "prefix-default", + }, + } + + for name, c := range cases { + configEntryKinds := map[string]struct { + ConsulKind string + ConsulNamespace string + KubeResource common.ConfigEntryResource + GetController func(client client.Client, logger logr.Logger, scheme *runtime.Scheme, cont *controller.ConfigEntryController) reconcile.Reconciler + AssertValidConfig func(entry capi.ConfigEntry) bool + }{ + "namespaced": { + ConsulKind: capi.ServiceDefaults, + KubeResource: &v1alpha1.ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: c.SourceKubeNS, + }, + Spec: v1alpha1.ServiceDefaultsSpec{ + Protocol: "http", + }, + }, + GetController: func(client client.Client, logger logr.Logger, scheme *runtime.Scheme, cont *controller.ConfigEntryController) reconcile.Reconciler { + return &controller.ServiceDefaultsController{ + Client: client, + Log: logger, + Scheme: scheme, + ConfigEntryController: cont, + } + }, + AssertValidConfig: func(cfg capi.ConfigEntry) bool { + configEntry, ok := cfg.(*capi.ServiceConfigEntry) + if !ok { + return false + } + return configEntry.Protocol == "http" + }, + ConsulNamespace: c.ExpConsulNS, + }, + "global": { + ConsulKind: capi.ProxyDefaults, + KubeResource: &v1alpha1.ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "global", + Namespace: c.SourceKubeNS, + }, + Spec: v1alpha1.ProxyDefaultsSpec{ + MeshGateway: v1alpha1.MeshGatewayConfig{ + Mode: "remote", + }, + }, + }, + GetController: func(client client.Client, logger logr.Logger, scheme *runtime.Scheme, cont *controller.ConfigEntryController) reconcile.Reconciler { + return &controller.ProxyDefaultsController{ + Client: client, + Log: logger, + Scheme: scheme, + ConfigEntryController: cont, + } + }, + AssertValidConfig: func(cfg capi.ConfigEntry) bool { + configEntry, ok := cfg.(*capi.ProxyConfigEntry) + if !ok { + return false + } + return configEntry.MeshGateway.Mode == capi.MeshGatewayModeRemote + }, + ConsulNamespace: common.DefaultConsulNamespace, + }, + "intentions": { + ConsulKind: capi.ServiceIntentions, + KubeResource: &v1alpha1.ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: c.SourceKubeNS, + }, + Spec: v1alpha1.ServiceIntentionsSpec{ + Destination: v1alpha1.Destination{ + Name: "test", + Namespace: c.SourceKubeNS, + }, + Sources: v1alpha1.SourceIntentions{ + &v1alpha1.SourceIntention{ + Name: "baz", + Namespace: "bar", + Action: "allow", + }, + }, + }, + }, + GetController: func(client client.Client, logger logr.Logger, scheme *runtime.Scheme, cont *controller.ConfigEntryController) reconcile.Reconciler { + return &controller.ServiceIntentionsController{ + Client: client, + Log: logger, + Scheme: scheme, + ConfigEntryController: cont, + } + }, + AssertValidConfig: func(cfg capi.ConfigEntry) bool { + configEntry, ok := cfg.(*capi.ServiceIntentionsConfigEntry) + if !ok { + return false + } + return configEntry.Sources[0].Action == capi.IntentionActionAllow + }, + ConsulNamespace: c.ExpConsulNS, + }, + } + + for kind, in := range configEntryKinds { + tt.Run(fmt.Sprintf("%s : %s", name, kind), func(t *testing.T) { + req := require.New(t) + s := runtime.NewScheme() + s.AddKnownTypes(v1alpha1.GroupVersion, in.KubeResource) + ctx := context.Background() + + consul, err := testutil.NewTestServerConfigT(t, nil) + req.NoError(err) + defer consul.Stop() + consul.WaitForServiceIntentions(t) + consulClient, err := capi.NewClient(&capi.Config{ + Address: consul.HTTPAddr, + }) + req.NoError(err) + + fakeClient := fake.NewFakeClientWithScheme(s, in.KubeResource) + + r := in.GetController( + fakeClient, + logrtest.TestLogger{T: t}, + s, + &controller.ConfigEntryController{ + ConsulClient: consulClient, + EnableConsulNamespaces: true, + EnableNSMirroring: c.Mirror, + NSMirroringPrefix: c.MirrorPrefix, + ConsulDestinationNamespace: c.DestConsulNS, + }, + ) + + resp, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: c.SourceKubeNS, + Name: in.KubeResource.KubernetesName(), + }, + }) + req.NoError(err) + req.False(resp.Requeue) + + cfg, _, err := consulClient.ConfigEntries().Get(in.ConsulKind, in.KubeResource.ConsulName(), &capi.QueryOptions{ + Namespace: in.ConsulNamespace, + }) + req.NoError(err) + + result := in.AssertValidConfig(cfg) + req.True(result) + + // Check that the status is "synced". + err = fakeClient.Get(ctx, types.NamespacedName{ + Namespace: c.SourceKubeNS, + Name: in.KubeResource.KubernetesName(), + }, in.KubeResource) + req.NoError(err) + conditionSynced := in.KubeResource.SyncedConditionStatus() + req.Equal(conditionSynced, corev1.ConditionTrue) + }) + } + } +} + +func TestConfigEntryController_updatesConfigEntry_consulNamespaces(tt *testing.T) { + tt.Parallel() + + cases := map[string]struct { + Mirror bool + MirrorPrefix string + SourceKubeNS string + DestConsulNS string + ExpConsulNS string + }{ + "SourceKubeNS=default, DestConsulNS=default": { + SourceKubeNS: "default", + DestConsulNS: "default", + ExpConsulNS: "default", + }, + "SourceKubeNS=kube, DestConsulNS=default": { + SourceKubeNS: "kube", + DestConsulNS: "default", + ExpConsulNS: "default", + }, + "SourceKubeNS=default, DestConsulNS=other": { + SourceKubeNS: "default", + DestConsulNS: "other", + ExpConsulNS: "other", + }, + "SourceKubeNS=kube, DestConsulNS=other": { + SourceKubeNS: "kube", + DestConsulNS: "other", + ExpConsulNS: "other", + }, + "SourceKubeNS=default, Mirror=true": { + SourceKubeNS: "default", + Mirror: true, + ExpConsulNS: "default", + }, + "SourceKubeNS=kube, Mirror=true": { + SourceKubeNS: "kube", + Mirror: true, + ExpConsulNS: "kube", + }, + "SourceKubeNS=default, Mirror=true, Prefix=prefix": { + SourceKubeNS: "default", + Mirror: true, + MirrorPrefix: "prefix-", + ExpConsulNS: "prefix-default", + }, + } + + for name, c := range cases { + configEntryKinds := map[string]struct { + ConsulKind string + ConsulNamespace string + KubeResource common.ConfigEntryResource + GetControllerFunc func(client client.Client, logger logr.Logger, scheme *runtime.Scheme, cont *controller.ConfigEntryController) reconcile.Reconciler + AssertValidConfigFunc func(entry capi.ConfigEntry) bool + WriteConfigEntryFunc func(consulClient *capi.Client, namespace string) error + UpdateResourceFunc func(client client.Client, ctx context.Context, in common.ConfigEntryResource) error + }{ + "namespaced": { + ConsulKind: capi.ServiceDefaults, + KubeResource: &v1alpha1.ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: c.SourceKubeNS, + Finalizers: []string{controller.FinalizerName}, + }, + Spec: v1alpha1.ServiceDefaultsSpec{ + Protocol: "http", + }, + }, + ConsulNamespace: c.ExpConsulNS, + GetControllerFunc: func(client client.Client, logger logr.Logger, scheme *runtime.Scheme, cont *controller.ConfigEntryController) reconcile.Reconciler { + return &controller.ServiceDefaultsController{ + Client: client, + Log: logger, + Scheme: scheme, + ConfigEntryController: cont, + } + }, + WriteConfigEntryFunc: func(consulClient *capi.Client, namespace string) error { + _, _, err := consulClient.ConfigEntries().Set(&capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "http", + }, &capi.WriteOptions{Namespace: namespace}) + return err + }, + UpdateResourceFunc: func(client client.Client, ctx context.Context, in common.ConfigEntryResource) error { + svcDefault := in.(*v1alpha1.ServiceDefaults) + svcDefault.Spec.Protocol = "tcp" + return client.Update(ctx, svcDefault) + }, + AssertValidConfigFunc: func(cfg capi.ConfigEntry) bool { + configEntry, ok := cfg.(*capi.ServiceConfigEntry) + if !ok { + return false + } + return configEntry.Protocol == "tcp" + }, + }, + "global": { + ConsulKind: capi.ProxyDefaults, + KubeResource: &v1alpha1.ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: common.Global, + Namespace: c.SourceKubeNS, + Finalizers: []string{controller.FinalizerName}, + }, + Spec: v1alpha1.ProxyDefaultsSpec{ + MeshGateway: v1alpha1.MeshGatewayConfig{ + Mode: "remote", + }, + }, + }, + ConsulNamespace: common.DefaultConsulNamespace, + GetControllerFunc: func(client client.Client, logger logr.Logger, scheme *runtime.Scheme, cont *controller.ConfigEntryController) reconcile.Reconciler { + return &controller.ProxyDefaultsController{ + Client: client, + Log: logger, + Scheme: scheme, + ConfigEntryController: cont, + } + }, + WriteConfigEntryFunc: func(consulClient *capi.Client, namespace string) error { + _, _, err := consulClient.ConfigEntries().Set(&capi.ProxyConfigEntry{ + Kind: capi.ProxyDefaults, + Name: common.Global, + MeshGateway: capi.MeshGatewayConfig{ + Mode: capi.MeshGatewayModeRemote, + }, + }, &capi.WriteOptions{Namespace: namespace}) + return err + }, + UpdateResourceFunc: func(client client.Client, ctx context.Context, in common.ConfigEntryResource) error { + proxyDefaults := in.(*v1alpha1.ProxyDefaults) + proxyDefaults.Spec.MeshGateway.Mode = "local" + return client.Update(ctx, proxyDefaults) + }, + AssertValidConfigFunc: func(cfg capi.ConfigEntry) bool { + configEntry, ok := cfg.(*capi.ProxyConfigEntry) + if !ok { + return false + } + return configEntry.MeshGateway.Mode == capi.MeshGatewayModeLocal + }, + }, + "intentions": { + ConsulKind: capi.ServiceIntentions, + KubeResource: &v1alpha1.ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: c.SourceKubeNS, + Finalizers: []string{controller.FinalizerName}, + }, + Spec: v1alpha1.ServiceIntentionsSpec{ + Destination: v1alpha1.Destination{ + Name: "foo", + Namespace: c.SourceKubeNS, + }, + Sources: v1alpha1.SourceIntentions{ + &v1alpha1.SourceIntention{ + Name: "bar", + Namespace: "baz", + Action: "deny", + }, + }, + }, + }, + ConsulNamespace: c.ExpConsulNS, + GetControllerFunc: func(client client.Client, logger logr.Logger, scheme *runtime.Scheme, cont *controller.ConfigEntryController) reconcile.Reconciler { + return &controller.ServiceIntentionsController{ + Client: client, + Log: logger, + Scheme: scheme, + ConfigEntryController: cont, + } + }, + WriteConfigEntryFunc: func(consulClient *capi.Client, namespace string) error { + _, _, err := consulClient.ConfigEntries().Set(&capi.ServiceIntentionsConfigEntry{ + Kind: capi.ServiceIntentions, + Name: "foo", + Sources: []*capi.SourceIntention{ + { + Name: "bar", + Namespace: "baz", + Action: capi.IntentionActionDeny, + }, + }, + }, &capi.WriteOptions{Namespace: namespace}) + return err + }, + UpdateResourceFunc: func(client client.Client, ctx context.Context, in common.ConfigEntryResource) error { + svcIntention := in.(*v1alpha1.ServiceIntentions) + svcIntention.Spec.Sources[0].Action = "allow" + return client.Update(ctx, svcIntention) + }, + AssertValidConfigFunc: func(cfg capi.ConfigEntry) bool { + configEntry, ok := cfg.(*capi.ServiceIntentionsConfigEntry) + if !ok { + return false + } + return configEntry.Sources[0].Action == capi.IntentionActionAllow + }, + }, + } + for kind, in := range configEntryKinds { + tt.Run(fmt.Sprintf("%s : %s", name, kind), func(t *testing.T) { + req := require.New(t) + s := runtime.NewScheme() + s.AddKnownTypes(v1alpha1.GroupVersion, in.KubeResource) + ctx := context.Background() + + consul, err := testutil.NewTestServerConfigT(t, nil) + req.NoError(err) + defer consul.Stop() + consul.WaitForServiceIntentions(t) + consulClient, err := capi.NewClient(&capi.Config{ + Address: consul.HTTPAddr, + }) + req.NoError(err) + + fakeClient := fake.NewFakeClientWithScheme(s, in.KubeResource) + + r := in.GetControllerFunc( + fakeClient, + logrtest.TestLogger{T: t}, + s, + &controller.ConfigEntryController{ + ConsulClient: consulClient, + EnableConsulNamespaces: true, + EnableNSMirroring: c.Mirror, + NSMirroringPrefix: c.MirrorPrefix, + ConsulDestinationNamespace: c.DestConsulNS, + }, + ) + + // We haven't run reconcile yet so ensure it's created in Consul. + { + if in.ConsulNamespace != "default" { + _, _, err := consulClient.Namespaces().Create(&capi.Namespace{ + Name: in.ConsulNamespace, + }, nil) + req.NoError(err) + } + + err := in.WriteConfigEntryFunc(consulClient, in.ConsulNamespace) + req.NoError(err) + } + + // Now update it. + { + // First get it so we have the latest revision number. + err = fakeClient.Get(ctx, types.NamespacedName{ + Namespace: c.SourceKubeNS, + Name: in.KubeResource.KubernetesName(), + }, in.KubeResource) + req.NoError(err) + + // Update the resource. + err := in.UpdateResourceFunc(fakeClient, ctx, in.KubeResource) + req.NoError(err) + + resp, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: c.SourceKubeNS, + Name: in.KubeResource.KubernetesName(), + }, + }) + req.NoError(err) + req.False(resp.Requeue) + + cfg, _, err := consulClient.ConfigEntries().Get(in.ConsulKind, in.KubeResource.ConsulName(), &capi.QueryOptions{ + Namespace: in.ConsulNamespace, + }) + req.NoError(err) + req.True(in.AssertValidConfigFunc(cfg)) + } + }) + } + } +} + +func TestConfigEntryController_deletesConfigEntry_consulNamespaces(tt *testing.T) { + tt.Parallel() + + cases := map[string]struct { + Mirror bool + MirrorPrefix string + SourceKubeNS string + DestConsulNS string + ExpConsulNS string + }{ + "SourceKubeNS=default, DestConsulNS=default": { + SourceKubeNS: "default", + DestConsulNS: "default", + ExpConsulNS: "default", + }, + "SourceKubeNS=kube, DestConsulNS=default": { + SourceKubeNS: "kube", + DestConsulNS: "default", + ExpConsulNS: "default", + }, + "SourceKubeNS=default, DestConsulNS=other": { + SourceKubeNS: "default", + DestConsulNS: "other", + ExpConsulNS: "other", + }, + "SourceKubeNS=kube, DestConsulNS=other": { + SourceKubeNS: "kube", + DestConsulNS: "other", + ExpConsulNS: "other", + }, + "SourceKubeNS=default, Mirror=true": { + SourceKubeNS: "default", + Mirror: true, + ExpConsulNS: "default", + }, + "SourceKubeNS=kube, Mirror=true": { + SourceKubeNS: "kube", + Mirror: true, + ExpConsulNS: "kube", + }, + "SourceKubeNS=default, Mirror=true, Prefix=prefix": { + SourceKubeNS: "default", + Mirror: true, + MirrorPrefix: "prefix-", + ExpConsulNS: "prefix-default", + }, + } + + for name, c := range cases { + configEntryKinds := map[string]struct { + ConsulKind string + ConsulNamespace string + KubeResource common.ConfigEntryResource + GetControllerFunc func(client client.Client, logger logr.Logger, scheme *runtime.Scheme, cont *controller.ConfigEntryController) reconcile.Reconciler + WriteConfigEntryFunc func(consulClient *capi.Client, namespace string) error + }{ + "namespaced": { + ConsulKind: capi.ServiceDefaults, + // Create it with the deletion timestamp set to mimic that it's already + // been marked for deletion. + KubeResource: &v1alpha1.ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: c.SourceKubeNS, + Finalizers: []string{controller.FinalizerName}, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + }, + Spec: v1alpha1.ServiceDefaultsSpec{ + Protocol: "http", + }, + }, + ConsulNamespace: c.ExpConsulNS, + GetControllerFunc: func(client client.Client, logger logr.Logger, scheme *runtime.Scheme, cont *controller.ConfigEntryController) reconcile.Reconciler { + return &controller.ServiceDefaultsController{ + Client: client, + Log: logger, + Scheme: scheme, + ConfigEntryController: cont, + } + }, + WriteConfigEntryFunc: func(consulClient *capi.Client, namespace string) error { + _, _, err := consulClient.ConfigEntries().Set(&capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "http", + }, &capi.WriteOptions{Namespace: namespace}) + return err + }, + }, + "global": { + ConsulKind: capi.ProxyDefaults, + // Create it with the deletion timestamp set to mimic that it's already + // been marked for deletion. + KubeResource: &v1alpha1.ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: common.Global, + Namespace: c.SourceKubeNS, + Finalizers: []string{controller.FinalizerName}, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + }, + Spec: v1alpha1.ProxyDefaultsSpec{ + MeshGateway: v1alpha1.MeshGatewayConfig{ + Mode: "remote", + }, + }, + }, + ConsulNamespace: common.DefaultConsulNamespace, + GetControllerFunc: func(client client.Client, logger logr.Logger, scheme *runtime.Scheme, cont *controller.ConfigEntryController) reconcile.Reconciler { + return &controller.ProxyDefaultsController{ + Client: client, + Log: logger, + Scheme: scheme, + ConfigEntryController: cont, + } + }, + WriteConfigEntryFunc: func(consulClient *capi.Client, namespace string) error { + _, _, err := consulClient.ConfigEntries().Set(&capi.ProxyConfigEntry{ + Kind: capi.ProxyDefaults, + Name: common.Global, + MeshGateway: capi.MeshGatewayConfig{ + Mode: capi.MeshGatewayModeRemote, + }, + }, &capi.WriteOptions{Namespace: namespace}) + return err + }, + }, + "intentions": { + ConsulKind: capi.ServiceIntentions, + // Create it with the deletion timestamp set to mimic that it's already + // been marked for deletion. + KubeResource: &v1alpha1.ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: c.SourceKubeNS, + Finalizers: []string{controller.FinalizerName}, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + }, + Spec: v1alpha1.ServiceIntentionsSpec{ + Destination: v1alpha1.Destination{ + Name: "test", + Namespace: c.SourceKubeNS, + }, + Sources: v1alpha1.SourceIntentions{ + &v1alpha1.SourceIntention{ + Name: "bar", + Namespace: "baz", + Action: "deny", + }, + }, + }, + }, + ConsulNamespace: c.ExpConsulNS, + GetControllerFunc: func(client client.Client, logger logr.Logger, scheme *runtime.Scheme, cont *controller.ConfigEntryController) reconcile.Reconciler { + return &controller.ServiceIntentionsController{ + Client: client, + Log: logger, + Scheme: scheme, + ConfigEntryController: cont, + } + }, + WriteConfigEntryFunc: func(consulClient *capi.Client, namespace string) error { + _, _, err := consulClient.ConfigEntries().Set(&capi.ServiceIntentionsConfigEntry{ + Kind: capi.ServiceIntentions, + Name: "test", + Sources: []*capi.SourceIntention{ + { + Name: "bar", + Namespace: "baz", + Action: capi.IntentionActionDeny, + }, + }, + }, &capi.WriteOptions{Namespace: namespace}) + return err + }, + }, + } + for kind, in := range configEntryKinds { + tt.Run(fmt.Sprintf("%s : %s", name, kind), func(t *testing.T) { + req := require.New(t) + + s := runtime.NewScheme() + s.AddKnownTypes(v1alpha1.GroupVersion, in.KubeResource) + + consul, err := testutil.NewTestServerConfigT(t, nil) + req.NoError(err) + defer consul.Stop() + consul.WaitForServiceIntentions(t) + consulClient, err := capi.NewClient(&capi.Config{ + Address: consul.HTTPAddr, + }) + req.NoError(err) + + fakeClient := fake.NewFakeClientWithScheme(s, in.KubeResource) + + r := in.GetControllerFunc( + fakeClient, + logrtest.TestLogger{T: t}, + s, + &controller.ConfigEntryController{ + ConsulClient: consulClient, + EnableConsulNamespaces: true, + EnableNSMirroring: c.Mirror, + NSMirroringPrefix: c.MirrorPrefix, + ConsulDestinationNamespace: c.DestConsulNS, + }, + ) + + // We haven't run reconcile yet so ensure it's created in Consul. + { + if in.ConsulNamespace != "default" { + _, _, err := consulClient.Namespaces().Create(&capi.Namespace{ + Name: in.ConsulNamespace, + }, nil) + req.NoError(err) + } + + err := in.WriteConfigEntryFunc(consulClient, in.ConsulNamespace) + req.NoError(err) + } + + // Now run reconcile. It's marked for deletion so this should delete it. + { + resp, err := r.Reconcile(ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: c.SourceKubeNS, + Name: in.KubeResource.KubernetesName(), + }, + }) + req.NoError(err) + req.False(resp.Requeue) + + _, _, err = consulClient.ConfigEntries().Get(in.ConsulKind, in.KubeResource.ConsulName(), &capi.QueryOptions{ + Namespace: in.ConsulNamespace, + }) + req.EqualError(err, fmt.Sprintf(`Unexpected response code: 404 (Config entry not found for "%s" / "%s")`, in.ConsulKind, in.KubeResource.ConsulName())) + } + }) + } + } +} diff --git a/controller/configentry_controller_test.go b/controller/configentry_controller_test.go new file mode 100644 index 0000000000..d350897c9e --- /dev/null +++ b/controller/configentry_controller_test.go @@ -0,0 +1,2075 @@ +package controller + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/go-logr/logr" + logrtest "github.com/go-logr/logr/testing" + "github.com/hashicorp/consul-k8s/api/common" + "github.com/hashicorp/consul-k8s/api/v1alpha1" + capi "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/sdk/testutil" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +const datacenterName = "datacenter" + +type testReconciler interface { + Reconcile(req ctrl.Request) (ctrl.Result, error) +} + +func TestConfigEntryControllers_createsConfigEntry(t *testing.T) { + t.Parallel() + kubeNS := "default" + + cases := []struct { + kubeKind string + consulKind string + consulPrereqs []capi.ConfigEntry + configEntryResource common.ConfigEntryResource + reconciler func(client.Client, *capi.Client, logr.Logger) testReconciler + compare func(t *testing.T, consul capi.ConfigEntry) + }{ + { + kubeKind: "ServiceDefaults", + consulKind: capi.ServiceDefaults, + configEntryResource: &v1alpha1.ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceDefaultsSpec{ + Protocol: "http", + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceDefaultsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + compare: func(t *testing.T, consulEntry capi.ConfigEntry) { + svcDefault, ok := consulEntry.(*capi.ServiceConfigEntry) + require.True(t, ok, "cast error") + require.Equal(t, "http", svcDefault.Protocol) + }, + }, + { + kubeKind: "ServiceResolver", + consulKind: capi.ServiceResolver, + configEntryResource: &v1alpha1.ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceResolverSpec{ + Redirect: &v1alpha1.ServiceResolverRedirect{ + Service: "redirect", + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceResolverController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + compare: func(t *testing.T, consulEntry capi.ConfigEntry) { + svcDefault, ok := consulEntry.(*capi.ServiceResolverConfigEntry) + require.True(t, ok, "cast error") + require.Equal(t, "redirect", svcDefault.Redirect.Service) + }, + }, + { + kubeKind: "ProxyDefaults", + consulKind: capi.ProxyDefaults, + configEntryResource: &v1alpha1.ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: common.Global, + Namespace: kubeNS, + }, + Spec: v1alpha1.ProxyDefaultsSpec{ + MeshGateway: v1alpha1.MeshGatewayConfig{ + Mode: "remote", + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ProxyDefaultsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + compare: func(t *testing.T, consulEntry capi.ConfigEntry) { + proxyDefault, ok := consulEntry.(*capi.ProxyConfigEntry) + require.True(t, ok, "cast error") + require.Equal(t, capi.MeshGatewayModeRemote, proxyDefault.MeshGateway.Mode) + }, + }, + { + kubeKind: "ServiceRouter", + consulKind: capi.ServiceRouter, + consulPrereqs: []capi.ConfigEntry{ + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "http", + }, + }, + configEntryResource: &v1alpha1.ServiceRouter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceRouterSpec{ + Routes: []v1alpha1.ServiceRoute{ + { + Match: &v1alpha1.ServiceRouteMatch{ + HTTP: &v1alpha1.ServiceRouteHTTPMatch{ + PathPrefix: "/admin", + }, + }, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceRouterController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + compare: func(t *testing.T, consulEntry capi.ConfigEntry) { + configEntry, ok := consulEntry.(*capi.ServiceRouterConfigEntry) + require.True(t, ok, "cast error") + require.Equal(t, "/admin", configEntry.Routes[0].Match.HTTP.PathPrefix) + }, + }, + { + kubeKind: "ServiceSplitter", + consulKind: capi.ServiceSplitter, + consulPrereqs: []capi.ConfigEntry{ + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "http", + }, + }, + configEntryResource: &v1alpha1.ServiceSplitter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceSplitterSpec{ + Splits: []v1alpha1.ServiceSplit{ + { + Weight: 100, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceSplitterController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + compare: func(t *testing.T, consulEntry capi.ConfigEntry) { + svcDefault, ok := consulEntry.(*capi.ServiceSplitterConfigEntry) + require.True(t, ok, "cast error") + require.Equal(t, float32(100), svcDefault.Splits[0].Weight) + }, + }, + { + kubeKind: "ServiceIntentions", + consulKind: capi.ServiceIntentions, + consulPrereqs: []capi.ConfigEntry{ + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "http", + }, + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "bar", + Protocol: "http", + }, + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "baz", + Protocol: "http", + }, + }, + configEntryResource: &v1alpha1.ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-name", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceIntentionsSpec{ + Destination: v1alpha1.Destination{ + Name: "foo", + }, + Sources: v1alpha1.SourceIntentions{ + &v1alpha1.SourceIntention{ + Name: "bar", + Action: "allow", + }, + &v1alpha1.SourceIntention{ + Name: "baz", + Action: "deny", + }, + &v1alpha1.SourceIntention{ + Name: "bax", + Permissions: v1alpha1.IntentionPermissions{ + &v1alpha1.IntentionPermission{ + Action: "allow", + HTTP: &v1alpha1.IntentionHTTPPermission{ + PathExact: "/path", + Header: v1alpha1.IntentionHTTPHeaderPermissions{ + v1alpha1.IntentionHTTPHeaderPermission{ + Name: "auth", + Present: true, + }, + }, + Methods: []string{ + "PUT", + "GET", + }, + }, + }, + }, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceIntentionsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + compare: func(t *testing.T, consulEntry capi.ConfigEntry) { + svcIntentions, ok := consulEntry.(*capi.ServiceIntentionsConfigEntry) + require.True(t, ok, "cast error") + require.Equal(t, "foo", svcIntentions.Name) + require.Equal(t, "bar", svcIntentions.Sources[0].Name) + require.Equal(t, capi.IntentionActionAllow, svcIntentions.Sources[0].Action) + require.Equal(t, "baz", svcIntentions.Sources[1].Name) + require.Equal(t, capi.IntentionActionDeny, svcIntentions.Sources[1].Action) + require.Equal(t, "bax", svcIntentions.Sources[2].Name) + require.Equal(t, capi.IntentionActionAllow, svcIntentions.Sources[2].Permissions[0].Action) + require.Equal(t, "/path", svcIntentions.Sources[2].Permissions[0].HTTP.PathExact) + }, + }, + } + + for _, c := range cases { + t.Run(c.kubeKind, func(t *testing.T) { + req := require.New(t) + ctx := context.Background() + + s := runtime.NewScheme() + s.AddKnownTypes(v1alpha1.GroupVersion, c.configEntryResource) + client := fake.NewFakeClientWithScheme(s, c.configEntryResource) + + consul, err := testutil.NewTestServerConfigT(t, nil) + req.NoError(err) + defer consul.Stop() + + consul.WaitForServiceIntentions(t) + consulClient, err := capi.NewClient(&capi.Config{ + Address: consul.HTTPAddr, + }) + req.NoError(err) + for _, configEntry := range c.consulPrereqs { + written, _, err := consulClient.ConfigEntries().Set(configEntry, nil) + req.NoError(err) + req.True(written) + } + + r := c.reconciler(client, consulClient, logrtest.TestLogger{T: t}) + namespacedName := types.NamespacedName{ + Namespace: kubeNS, + Name: c.configEntryResource.KubernetesName(), + } + resp, err := r.Reconcile(ctrl.Request{ + NamespacedName: namespacedName, + }) + req.NoError(err) + req.False(resp.Requeue) + + cfg, _, err := consulClient.ConfigEntries().Get(c.consulKind, c.configEntryResource.ConsulName(), nil) + req.NoError(err) + req.Equal(c.configEntryResource.ConsulName(), cfg.GetName()) + c.compare(t, cfg) + + // Check that the status is "synced". + err = client.Get(ctx, namespacedName, c.configEntryResource) + req.NoError(err) + req.Equal(corev1.ConditionTrue, c.configEntryResource.SyncedConditionStatus()) + + // Check that the finalizer is added. + req.Contains(c.configEntryResource.Finalizers(), FinalizerName) + }) + } +} + +func TestConfigEntryControllers_updatesConfigEntry(t *testing.T) { + t.Parallel() + kubeNS := "default" + + cases := []struct { + kubeKind string + consulKind string + consulPrereqs []capi.ConfigEntry + configEntryResource common.ConfigEntryResource + reconciler func(client.Client, *capi.Client, logr.Logger) testReconciler + updateF func(common.ConfigEntryResource) + compare func(t *testing.T, consul capi.ConfigEntry) + }{ + { + kubeKind: "ServiceDefaults", + consulKind: capi.ServiceDefaults, + configEntryResource: &v1alpha1.ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceDefaultsSpec{ + Protocol: "http", + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceDefaultsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + updateF: func(resource common.ConfigEntryResource) { + svcDefaults := resource.(*v1alpha1.ServiceDefaults) + svcDefaults.Spec.Protocol = "tcp" + }, + compare: func(t *testing.T, consulEntry capi.ConfigEntry) { + svcDefault, ok := consulEntry.(*capi.ServiceConfigEntry) + require.True(t, ok, "cast error") + require.Equal(t, "tcp", svcDefault.Protocol) + }, + }, + { + kubeKind: "ServiceResolver", + consulKind: capi.ServiceResolver, + configEntryResource: &v1alpha1.ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceResolverSpec{ + Redirect: &v1alpha1.ServiceResolverRedirect{ + Service: "redirect", + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceResolverController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + updateF: func(resource common.ConfigEntryResource) { + svcResolver := resource.(*v1alpha1.ServiceResolver) + svcResolver.Spec.Redirect.Service = "different_redirect" + }, + compare: func(t *testing.T, consulEntry capi.ConfigEntry) { + svcDefault, ok := consulEntry.(*capi.ServiceResolverConfigEntry) + require.True(t, ok, "cast error") + require.Equal(t, "different_redirect", svcDefault.Redirect.Service) + }, + }, + { + kubeKind: "ProxyDefaults", + consulKind: capi.ProxyDefaults, + configEntryResource: &v1alpha1.ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: common.Global, + Namespace: kubeNS, + }, + Spec: v1alpha1.ProxyDefaultsSpec{ + MeshGateway: v1alpha1.MeshGatewayConfig{ + Mode: "remote", + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ProxyDefaultsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + updateF: func(resource common.ConfigEntryResource) { + proxyDefault := resource.(*v1alpha1.ProxyDefaults) + proxyDefault.Spec.MeshGateway.Mode = "local" + }, + compare: func(t *testing.T, consulEntry capi.ConfigEntry) { + proxyDefault, ok := consulEntry.(*capi.ProxyConfigEntry) + require.True(t, ok, "cast error") + require.Equal(t, capi.MeshGatewayModeLocal, proxyDefault.MeshGateway.Mode) + }, + }, + { + kubeKind: "ServiceSplitter", + consulKind: capi.ServiceSplitter, + consulPrereqs: []capi.ConfigEntry{ + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "http", + }, + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "bar", + Protocol: "http", + }, + }, + configEntryResource: &v1alpha1.ServiceSplitter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceSplitterSpec{ + Splits: []v1alpha1.ServiceSplit{ + { + Weight: 100, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceSplitterController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + updateF: func(resource common.ConfigEntryResource) { + serviceSplitter := resource.(*v1alpha1.ServiceSplitter) + serviceSplitter.Spec.Splits = []v1alpha1.ServiceSplit{ + { + Weight: 80, + }, + { + Weight: 20, + Service: "bar", + }, + } + }, + compare: func(t *testing.T, consulEntry capi.ConfigEntry) { + svcSplitter, ok := consulEntry.(*capi.ServiceSplitterConfigEntry) + require.True(t, ok, "cast error") + require.Equal(t, float32(80), svcSplitter.Splits[0].Weight) + require.Equal(t, float32(20), svcSplitter.Splits[1].Weight) + require.Equal(t, "bar", svcSplitter.Splits[1].Service) + }, + }, + { + kubeKind: "ServiceRouter", + consulKind: capi.ServiceRouter, + consulPrereqs: []capi.ConfigEntry{ + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "http", + }, + }, + configEntryResource: &v1alpha1.ServiceRouter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceRouterSpec{ + Routes: []v1alpha1.ServiceRoute{ + { + Match: &v1alpha1.ServiceRouteMatch{ + HTTP: &v1alpha1.ServiceRouteHTTPMatch{ + PathPrefix: "/admin", + }, + }, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceRouterController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + updateF: func(resource common.ConfigEntryResource) { + svcRouter := resource.(*v1alpha1.ServiceRouter) + svcRouter.Spec.Routes[0].Match.HTTP.PathPrefix = "/different_path" + }, + compare: func(t *testing.T, consulEntry capi.ConfigEntry) { + configEntry, ok := consulEntry.(*capi.ServiceRouterConfigEntry) + require.True(t, ok, "cast error") + require.Equal(t, "/different_path", configEntry.Routes[0].Match.HTTP.PathPrefix) + }, + }, + { + kubeKind: "ServiceIntentions", + consulKind: capi.ServiceIntentions, + consulPrereqs: []capi.ConfigEntry{ + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "http", + }, + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "bar", + Protocol: "http", + }, + }, + configEntryResource: &v1alpha1.ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceIntentionsSpec{ + Destination: v1alpha1.Destination{ + Name: "foo", + }, + Sources: v1alpha1.SourceIntentions{ + &v1alpha1.SourceIntention{ + Name: "bar", + Action: "allow", + }, + &v1alpha1.SourceIntention{ + Name: "baz", + Permissions: v1alpha1.IntentionPermissions{ + &v1alpha1.IntentionPermission{ + Action: "allow", + HTTP: &v1alpha1.IntentionHTTPPermission{ + PathExact: "/path", + Header: v1alpha1.IntentionHTTPHeaderPermissions{ + v1alpha1.IntentionHTTPHeaderPermission{ + Name: "auth", + Present: true, + }, + }, + Methods: []string{ + "PUT", + "GET", + }, + }, + }, + }, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceIntentionsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + updateF: func(resource common.ConfigEntryResource) { + svcIntentions := resource.(*v1alpha1.ServiceIntentions) + svcIntentions.Spec.Sources[0].Action = "deny" + svcIntentions.Spec.Sources[1].Permissions[0].Action = "deny" + }, + compare: func(t *testing.T, consulEntry capi.ConfigEntry) { + configEntry, ok := consulEntry.(*capi.ServiceIntentionsConfigEntry) + require.True(t, ok, "cast error") + require.Equal(t, capi.IntentionActionDeny, configEntry.Sources[0].Action) + require.Equal(t, capi.IntentionActionDeny, configEntry.Sources[1].Permissions[0].Action) + }, + }, + } + + for _, c := range cases { + t.Run(c.kubeKind, func(t *testing.T) { + req := require.New(t) + ctx := context.Background() + + s := runtime.NewScheme() + s.AddKnownTypes(v1alpha1.GroupVersion, c.configEntryResource) + client := fake.NewFakeClientWithScheme(s, c.configEntryResource) + + consul, err := testutil.NewTestServerConfigT(t, nil) + req.NoError(err) + defer consul.Stop() + + consul.WaitForServiceIntentions(t) + consulClient, err := capi.NewClient(&capi.Config{ + Address: consul.HTTPAddr, + }) + req.NoError(err) + + // Create any prereqs. + for _, configEntry := range c.consulPrereqs { + written, _, err := consulClient.ConfigEntries().Set(configEntry, nil) + req.NoError(err) + req.True(written) + } + + // We haven't run reconcile yet so we must create the config entry + // in Consul ourselves. + { + written, _, err := consulClient.ConfigEntries().Set(c.configEntryResource.ToConsul(datacenterName), nil) + req.NoError(err) + req.True(written) + } + + // Now run reconcile which should update the entry in Consul. + { + namespacedName := types.NamespacedName{ + Namespace: kubeNS, + Name: c.configEntryResource.KubernetesName(), + } + // First get it so we have the latest revision number. + err = client.Get(ctx, namespacedName, c.configEntryResource) + req.NoError(err) + + // Update the entry in Kube and run reconcile. + c.updateF(c.configEntryResource) + err := client.Update(ctx, c.configEntryResource) + req.NoError(err) + r := c.reconciler(client, consulClient, logrtest.TestLogger{T: t}) + resp, err := r.Reconcile(ctrl.Request{ + NamespacedName: namespacedName, + }) + req.NoError(err) + req.False(resp.Requeue) + + // Now check that the object in Consul is as expected. + cfg, _, err := consulClient.ConfigEntries().Get(c.consulKind, c.configEntryResource.ConsulName(), nil) + req.NoError(err) + req.Equal(c.configEntryResource.ConsulName(), cfg.GetName()) + c.compare(t, cfg) + } + }) + } +} + +func TestConfigEntryControllers_deletesConfigEntry(t *testing.T) { + t.Parallel() + kubeNS := "default" + + cases := []struct { + kubeKind string + consulKind string + consulPrereq []capi.ConfigEntry + configEntryResourceWithDeletion common.ConfigEntryResource + reconciler func(client.Client, *capi.Client, logr.Logger) testReconciler + }{ + { + kubeKind: "ServiceDefaults", + consulKind: capi.ServiceDefaults, + configEntryResourceWithDeletion: &v1alpha1.ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{FinalizerName}, + }, + Spec: v1alpha1.ServiceDefaultsSpec{ + Protocol: "http", + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceDefaultsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + { + kubeKind: "ServiceResolver", + consulKind: capi.ServiceResolver, + configEntryResourceWithDeletion: &v1alpha1.ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{FinalizerName}, + }, + Spec: v1alpha1.ServiceResolverSpec{ + Redirect: &v1alpha1.ServiceResolverRedirect{ + Service: "redirect", + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceResolverController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + { + kubeKind: "ProxyDefaults", + consulKind: capi.ProxyDefaults, + configEntryResourceWithDeletion: &v1alpha1.ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: common.Global, + Namespace: kubeNS, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{FinalizerName}, + }, + Spec: v1alpha1.ProxyDefaultsSpec{ + MeshGateway: v1alpha1.MeshGatewayConfig{ + Mode: "remote", + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ProxyDefaultsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + { + kubeKind: "ServiceRouter", + consulKind: capi.ServiceRouter, + consulPrereq: []capi.ConfigEntry{ + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "http", + }, + }, + configEntryResourceWithDeletion: &v1alpha1.ServiceRouter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{FinalizerName}, + }, + Spec: v1alpha1.ServiceRouterSpec{ + Routes: []v1alpha1.ServiceRoute{ + { + Match: &v1alpha1.ServiceRouteMatch{ + HTTP: &v1alpha1.ServiceRouteHTTPMatch{ + PathPrefix: "/admin", + }, + }, + }, + }, + }, + }, + + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceRouterController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + { + kubeKind: "ServiceSplitter", + consulKind: capi.ServiceSplitter, + consulPrereq: []capi.ConfigEntry{ + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "http", + }, + }, + configEntryResourceWithDeletion: &v1alpha1.ServiceSplitter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{FinalizerName}, + }, + Spec: v1alpha1.ServiceSplitterSpec{ + Splits: []v1alpha1.ServiceSplit{ + { + Weight: 100, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceSplitterController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + { + kubeKind: "ServiceIntentions", + consulKind: capi.ServiceIntentions, + consulPrereq: []capi.ConfigEntry{ + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "http", + }, + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "bar", + Protocol: "http", + }, + }, + configEntryResourceWithDeletion: &v1alpha1.ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + Namespace: kubeNS, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{FinalizerName}, + }, + Spec: v1alpha1.ServiceIntentionsSpec{ + Destination: v1alpha1.Destination{ + Name: "foo", + }, + Sources: v1alpha1.SourceIntentions{ + &v1alpha1.SourceIntention{ + Name: "bar", + Action: "allow", + }, + &v1alpha1.SourceIntention{ + Name: "baz", + Permissions: v1alpha1.IntentionPermissions{ + &v1alpha1.IntentionPermission{ + Action: "allow", + HTTP: &v1alpha1.IntentionHTTPPermission{ + PathExact: "/path", + Header: v1alpha1.IntentionHTTPHeaderPermissions{ + v1alpha1.IntentionHTTPHeaderPermission{ + Name: "auth", + Present: true, + }, + }, + Methods: []string{ + "PUT", + "GET", + }, + }, + }, + }, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceIntentionsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + } + + for _, c := range cases { + t.Run(c.kubeKind, func(t *testing.T) { + req := require.New(t) + + s := runtime.NewScheme() + s.AddKnownTypes(v1alpha1.GroupVersion, c.configEntryResourceWithDeletion) + client := fake.NewFakeClientWithScheme(s, c.configEntryResourceWithDeletion) + + consul, err := testutil.NewTestServerConfigT(t, nil) + req.NoError(err) + defer consul.Stop() + + consul.WaitForServiceIntentions(t) + consulClient, err := capi.NewClient(&capi.Config{ + Address: consul.HTTPAddr, + }) + req.NoError(err) + + // Create any prereqs. + for _, configEntry := range c.consulPrereq { + written, _, err := consulClient.ConfigEntries().Set(configEntry, nil) + req.NoError(err) + req.True(written) + } + + // We haven't run reconcile yet so we must create the config entry + // in Consul ourselves. + { + written, _, err := consulClient.ConfigEntries().Set(c.configEntryResourceWithDeletion.ToConsul(datacenterName), nil) + req.NoError(err) + req.True(written) + } + + // Now run reconcile. It's marked for deletion so this should delete it. + { + namespacedName := types.NamespacedName{ + Namespace: kubeNS, + Name: c.configEntryResourceWithDeletion.KubernetesName(), + } + r := c.reconciler(client, consulClient, logrtest.TestLogger{T: t}) + resp, err := r.Reconcile(ctrl.Request{ + NamespacedName: namespacedName, + }) + req.NoError(err) + req.False(resp.Requeue) + + _, _, err = consulClient.ConfigEntries().Get(c.consulKind, c.configEntryResourceWithDeletion.ConsulName(), nil) + req.EqualError(err, + fmt.Sprintf("Unexpected response code: 404 (Config entry not found for %q / %q)", + c.consulKind, c.configEntryResourceWithDeletion.ConsulName())) + } + }) + } +} + +func TestConfigEntryControllers_errorUpdatesSyncStatus(t *testing.T) { + t.Parallel() + kubeNS := "default" + + cases := []struct { + kubeKind string + consulKind string + configEntryResource common.ConfigEntryResource + reconciler func(client.Client, *capi.Client, logr.Logger) testReconciler + }{ + { + kubeKind: "ServiceDefaults", + consulKind: capi.ServiceDefaults, + configEntryResource: &v1alpha1.ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceDefaultsSpec{ + Protocol: "http", + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceDefaultsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + { + kubeKind: "ServiceResolver", + consulKind: capi.ServiceResolver, + configEntryResource: &v1alpha1.ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceResolverSpec{ + Redirect: &v1alpha1.ServiceResolverRedirect{ + Service: "redirect", + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceResolverController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + { + kubeKind: "ProxyDefaults", + consulKind: capi.ProxyDefaults, + configEntryResource: &v1alpha1.ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: common.Global, + Namespace: kubeNS, + }, + Spec: v1alpha1.ProxyDefaultsSpec{ + MeshGateway: v1alpha1.MeshGatewayConfig{ + Mode: "remote", + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ProxyDefaultsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + { + kubeKind: "ServiceRouter", + consulKind: capi.ServiceRouter, + configEntryResource: &v1alpha1.ServiceRouter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceRouterSpec{ + Routes: []v1alpha1.ServiceRoute{ + { + Match: &v1alpha1.ServiceRouteMatch{ + HTTP: &v1alpha1.ServiceRouteHTTPMatch{ + PathPrefix: "/admin", + }, + }, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceRouterController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + { + kubeKind: "ServiceIntentions", + consulKind: capi.ServiceIntentions, + configEntryResource: &v1alpha1.ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceIntentionsSpec{ + Destination: v1alpha1.Destination{ + Name: "foo", + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceIntentionsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + } + + for _, c := range cases { + t.Run(c.kubeKind, func(t *testing.T) { + req := require.New(t) + ctx := context.Background() + + s := runtime.NewScheme() + s.AddKnownTypes(v1alpha1.GroupVersion, c.configEntryResource) + client := fake.NewFakeClientWithScheme(s, c.configEntryResource) + + // Construct a Consul client that will error by giving it + // an unresolvable address. + consulClient, err := capi.NewClient(&capi.Config{ + Address: "incorrect-address", + }) + req.NoError(err) + + // ReconcileEntry should result in an error. + r := c.reconciler(client, consulClient, logrtest.TestLogger{T: t}) + namespacedName := types.NamespacedName{ + Namespace: kubeNS, + Name: c.configEntryResource.KubernetesName(), + } + resp, err := r.Reconcile(ctrl.Request{ + NamespacedName: namespacedName, + }) + req.Error(err) + + expErr := fmt.Sprintf("Get \"http://incorrect-address/v1/config/%s/%s\": dial tcp: lookup incorrect-address", c.consulKind, c.configEntryResource.ConsulName()) + req.Contains(err.Error(), expErr) + req.False(resp.Requeue) + + // Check that the status is "synced=false". + err = client.Get(ctx, namespacedName, c.configEntryResource) + req.NoError(err) + status, reason, errMsg := c.configEntryResource.SyncedCondition() + req.Equal(corev1.ConditionFalse, status) + req.Equal("ConsulAgentError", reason) + req.Contains(errMsg, expErr) + }) + } +} + +// Test that if the config entry hasn't changed in Consul but our resource +// synced status isn't set to true then we update its status. +func TestConfigEntryControllers_setsSyncedToTrue(t *testing.T) { + t.Parallel() + kubeNS := "default" + + cases := []struct { + kubeKind string + consulKind string + consulPrereq capi.ConfigEntry + configEntryResource common.ConfigEntryResource + reconciler func(client.Client, *capi.Client, logr.Logger) testReconciler + }{ + { + kubeKind: "ServiceDefaults", + consulKind: capi.ServiceDefaults, + configEntryResource: &v1alpha1.ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceDefaultsSpec{ + Protocol: "http", + }, + 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 &ServiceDefaultsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + { + kubeKind: "ServiceResolver", + consulKind: capi.ServiceResolver, + configEntryResource: &v1alpha1.ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceResolverSpec{ + Redirect: &v1alpha1.ServiceResolverRedirect{ + Service: "redirect", + }, + }, + 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 &ServiceResolverController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + { + kubeKind: "ProxyDefaults", + consulKind: capi.ProxyDefaults, + configEntryResource: &v1alpha1.ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: common.Global, + Namespace: kubeNS, + }, + Spec: v1alpha1.ProxyDefaultsSpec{ + MeshGateway: v1alpha1.MeshGatewayConfig{ + Mode: "remote", + }, + }, + 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 &ProxyDefaultsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + { + kubeKind: "ServiceRouter", + consulKind: capi.ServiceRouter, + consulPrereq: &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "http", + }, + configEntryResource: &v1alpha1.ServiceRouter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceRouterSpec{ + Routes: []v1alpha1.ServiceRoute{ + { + Match: &v1alpha1.ServiceRouteMatch{ + HTTP: &v1alpha1.ServiceRouteHTTPMatch{ + PathPrefix: "/admin", + }, + }, + }, + }, + }, + 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 &ServiceRouterController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + { + kubeKind: "ServiceIntentions", + consulKind: capi.ServiceIntentions, + consulPrereq: &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "http", + }, + configEntryResource: &v1alpha1.ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceIntentionsSpec{ + Destination: v1alpha1.Destination{ + Name: "foo", + }, + Sources: v1alpha1.SourceIntentions{ + &v1alpha1.SourceIntention{ + Name: "bar", + Action: "deny", + }, + &v1alpha1.SourceIntention{ + Name: "baz", + Permissions: v1alpha1.IntentionPermissions{ + &v1alpha1.IntentionPermission{ + Action: "allow", + HTTP: &v1alpha1.IntentionHTTPPermission{ + PathExact: "/path", + Header: v1alpha1.IntentionHTTPHeaderPermissions{ + v1alpha1.IntentionHTTPHeaderPermission{ + Name: "auth", + Present: true, + }, + }, + Methods: []string{ + "PUT", + "GET", + }, + }, + }, + }, + }, + }, + }, + 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 &ServiceIntentionsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + } + + for _, c := range cases { + t.Run(c.kubeKind, func(t *testing.T) { + req := require.New(t) + ctx := context.Background() + + s := runtime.NewScheme() + s.AddKnownTypes(v1alpha1.GroupVersion, c.configEntryResource) + + // The config entry exists in kube but its status will be nil. + client := fake.NewFakeClientWithScheme(s, c.configEntryResource) + + consul, err := testutil.NewTestServerConfigT(t, nil) + req.NoError(err) + defer consul.Stop() + + consul.WaitForServiceIntentions(t) + consulClient, err := capi.NewClient(&capi.Config{ + Address: consul.HTTPAddr, + }) + req.NoError(err) + + // Create any prereqs. + if c.consulPrereq != nil { + written, _, err := consulClient.ConfigEntries().Set(c.consulPrereq, nil) + req.NoError(err) + req.True(written) + } + + // Create the resource in Consul to mimic that it was created + // successfully (but its status hasn't been updated). + _, _, err = consulClient.ConfigEntries().Set(c.configEntryResource.ToConsul(datacenterName), nil) + require.NoError(t, err) + + r := c.reconciler(client, consulClient, logrtest.TestLogger{T: t}) + namespacedName := types.NamespacedName{ + Namespace: kubeNS, + Name: c.configEntryResource.KubernetesName(), + } + resp, err := r.Reconcile(ctrl.Request{ + NamespacedName: namespacedName, + }) + req.NoError(err) + req.False(resp.Requeue) + + // Check that the status is now "synced". + err = client.Get(ctx, namespacedName, c.configEntryResource) + req.NoError(err) + req.Equal(corev1.ConditionTrue, c.configEntryResource.SyncedConditionStatus()) + }) + } +} + +// Test that if the config entry exists in Consul but is not managed by the +// controller, creating/updating the resource fails +func TestConfigEntryControllers_doesNotCreateUnownedConfigEntry(t *testing.T) { + t.Parallel() + kubeNS := "default" + + cases := []struct { + kubeKind string + consulKind string + consulPrereqs []capi.ConfigEntry + configEntryResource common.ConfigEntryResource + reconciler func(client.Client, *capi.Client, logr.Logger) testReconciler + }{ + { + kubeKind: "ServiceDefaults", + consulKind: capi.ServiceDefaults, + configEntryResource: &v1alpha1.ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceDefaultsSpec{ + Protocol: "http", + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceDefaultsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + { + kubeKind: "ServiceResolver", + consulKind: capi.ServiceResolver, + configEntryResource: &v1alpha1.ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceResolverSpec{ + Redirect: &v1alpha1.ServiceResolverRedirect{ + Service: "redirect", + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceResolverController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + { + kubeKind: "ProxyDefaults", + consulKind: capi.ProxyDefaults, + configEntryResource: &v1alpha1.ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: common.Global, + Namespace: kubeNS, + }, + Spec: v1alpha1.ProxyDefaultsSpec{ + MeshGateway: v1alpha1.MeshGatewayConfig{ + Mode: "remote", + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ProxyDefaultsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + { + kubeKind: "ServiceSplitter", + consulKind: capi.ServiceSplitter, + consulPrereqs: []capi.ConfigEntry{ + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "http", + }, + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "bar", + Protocol: "http", + }, + }, + configEntryResource: &v1alpha1.ServiceSplitter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceSplitterSpec{ + Splits: []v1alpha1.ServiceSplit{ + { + Weight: 100, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceSplitterController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + { + kubeKind: "ServiceIntentions", + consulKind: capi.ServiceIntentions, + consulPrereqs: []capi.ConfigEntry{ + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "http", + }, + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "bar", + Protocol: "http", + }, + }, + configEntryResource: &v1alpha1.ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceIntentionsSpec{ + Destination: v1alpha1.Destination{ + Name: "foo", + }, + Sources: v1alpha1.SourceIntentions{ + &v1alpha1.SourceIntention{ + Name: "bar", + Action: "deny", + }, + &v1alpha1.SourceIntention{ + Name: "baz", + Permissions: v1alpha1.IntentionPermissions{ + &v1alpha1.IntentionPermission{ + Action: "allow", + HTTP: &v1alpha1.IntentionHTTPPermission{ + PathExact: "/path", + Header: v1alpha1.IntentionHTTPHeaderPermissions{ + v1alpha1.IntentionHTTPHeaderPermission{ + Name: "auth", + Present: true, + }, + }, + Methods: []string{ + "PUT", + "GET", + }, + }, + }, + }, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceIntentionsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + { + kubeKind: "ServiceRouter", + consulKind: capi.ServiceRouter, + consulPrereqs: []capi.ConfigEntry{ + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "http", + }, + }, + configEntryResource: &v1alpha1.ServiceRouter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + }, + Spec: v1alpha1.ServiceRouterSpec{ + Routes: []v1alpha1.ServiceRoute{ + { + Match: &v1alpha1.ServiceRouteMatch{ + HTTP: &v1alpha1.ServiceRouteHTTPMatch{ + PathPrefix: "/admin", + }, + }, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceRouterController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + }, + } + + for _, c := range cases { + t.Run(c.kubeKind, func(t *testing.T) { + req := require.New(t) + ctx := context.Background() + + s := runtime.NewScheme() + s.AddKnownTypes(v1alpha1.GroupVersion, c.configEntryResource) + client := fake.NewFakeClientWithScheme(s, c.configEntryResource) + + consul, err := testutil.NewTestServerConfigT(t, nil) + req.NoError(err) + defer consul.Stop() + + consul.WaitForServiceIntentions(t) + consulClient, err := capi.NewClient(&capi.Config{ + Address: consul.HTTPAddr, + }) + req.NoError(err) + + // Create any prereqs. + for _, configEntry := range c.consulPrereqs { + written, _, err := consulClient.ConfigEntries().Set(configEntry, nil) + req.NoError(err) + req.True(written) + } + + // We haven't run reconcile yet. We must create the config entry + // in Consul ourselves in a different datacenter. + { + written, _, err := consulClient.ConfigEntries().Set(c.configEntryResource.ToConsul("different-datacenter"), nil) + req.NoError(err) + req.True(written) + } + + // Now run reconcile which should **not** update the entry in Consul. + { + namespacedName := types.NamespacedName{ + Namespace: kubeNS, + Name: c.configEntryResource.KubernetesName(), + } + // First get it so we have the latest revision number. + err = client.Get(ctx, namespacedName, c.configEntryResource) + req.NoError(err) + + // Attempt to create the entry in Kube and run reconcile. + r := c.reconciler(client, consulClient, logrtest.TestLogger{T: t}) + resp, err := r.Reconcile(ctrl.Request{ + NamespacedName: namespacedName, + }) + req.EqualError(err, "config entry managed in different datacenter: \"different-datacenter\"") + req.False(resp.Requeue) + + // Now check that the object in Consul is as expected. + cfg, _, err := consulClient.ConfigEntries().Get(c.consulKind, c.configEntryResource.ConsulName(), nil) + req.NoError(err) + req.Equal(cfg.GetMeta()[common.DatacenterKey], "different-datacenter") + + // Check that the status is "synced=false". + err = client.Get(ctx, namespacedName, c.configEntryResource) + req.NoError(err) + status, reason, errMsg := c.configEntryResource.SyncedCondition() + req.Equal(corev1.ConditionFalse, status) + req.Equal("ExternallyManagedConfigError", reason) + req.Equal(errMsg, "config entry managed in different datacenter: \"different-datacenter\"") + } + }) + } +} + +// Test that if the config entry exists in Consul but is not managed by the +// controller, deleting the resource does not delete the Consul config entry +func TestConfigEntryControllers_doesNotDeleteUnownedConfig(t *testing.T) { + t.Parallel() + kubeNS := "default" + + cases := []struct { + kubeKind string + consulKind string + consulPrereq []capi.ConfigEntry + configEntryResourceWithDeletion common.ConfigEntryResource + reconciler func(client.Client, *capi.Client, logr.Logger) testReconciler + confirmDelete func(*testing.T, client.Client, context.Context, types.NamespacedName) + }{ + { + kubeKind: "ServiceDefaults", + consulKind: capi.ServiceDefaults, + configEntryResourceWithDeletion: &v1alpha1.ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{FinalizerName}, + }, + Spec: v1alpha1.ServiceDefaultsSpec{ + Protocol: "http", + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceDefaultsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + confirmDelete: func(t *testing.T, cli client.Client, ctx context.Context, name types.NamespacedName) { + svcDefault := &v1alpha1.ServiceDefaults{} + _ = cli.Get(ctx, name, svcDefault) + require.Empty(t, svcDefault.Finalizers()) + }, + }, + { + kubeKind: "ServiceResolver", + consulKind: capi.ServiceResolver, + configEntryResourceWithDeletion: &v1alpha1.ServiceResolver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{FinalizerName}, + }, + Spec: v1alpha1.ServiceResolverSpec{ + Redirect: &v1alpha1.ServiceResolverRedirect{ + Service: "redirect", + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceResolverController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + confirmDelete: func(t *testing.T, cli client.Client, ctx context.Context, name types.NamespacedName) { + svcResolver := &v1alpha1.ServiceResolver{} + _ = cli.Get(ctx, name, svcResolver) + require.Empty(t, svcResolver.Finalizers()) + }, + }, + { + kubeKind: "ProxyDefaults", + consulKind: capi.ProxyDefaults, + configEntryResourceWithDeletion: &v1alpha1.ProxyDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: common.Global, + Namespace: kubeNS, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{FinalizerName}, + }, + Spec: v1alpha1.ProxyDefaultsSpec{ + MeshGateway: v1alpha1.MeshGatewayConfig{ + Mode: "remote", + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ProxyDefaultsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + confirmDelete: func(t *testing.T, cli client.Client, ctx context.Context, name types.NamespacedName) { + proxyDefault := &v1alpha1.ProxyDefaults{} + _ = cli.Get(ctx, name, proxyDefault) + require.Empty(t, proxyDefault.Finalizers()) + }, + }, + { + kubeKind: "ServiceRouter", + consulKind: capi.ServiceRouter, + consulPrereq: []capi.ConfigEntry{ + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "http", + }, + }, + configEntryResourceWithDeletion: &v1alpha1.ServiceRouter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{FinalizerName}, + }, + Spec: v1alpha1.ServiceRouterSpec{ + Routes: []v1alpha1.ServiceRoute{ + { + Match: &v1alpha1.ServiceRouteMatch{ + HTTP: &v1alpha1.ServiceRouteHTTPMatch{ + PathPrefix: "/admin", + }, + }, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceRouterController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + confirmDelete: func(t *testing.T, cli client.Client, ctx context.Context, name types.NamespacedName) { + svcRouter := &v1alpha1.ServiceRouter{} + _ = cli.Get(ctx, name, svcRouter) + require.Empty(t, svcRouter.Finalizers()) + }, + }, + { + kubeKind: "ServiceSplitter", + consulKind: capi.ServiceSplitter, + consulPrereq: []capi.ConfigEntry{ + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "http", + }, + }, + configEntryResourceWithDeletion: &v1alpha1.ServiceSplitter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: kubeNS, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{FinalizerName}, + }, + Spec: v1alpha1.ServiceSplitterSpec{ + Splits: []v1alpha1.ServiceSplit{ + { + Weight: 100, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceSplitterController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + confirmDelete: func(t *testing.T, cli client.Client, ctx context.Context, name types.NamespacedName) { + svcSplitter := &v1alpha1.ServiceSplitter{} + _ = cli.Get(ctx, name, svcSplitter) + require.Empty(t, svcSplitter.Finalizers()) + }, + }, + { + kubeKind: "ServiceIntentions", + consulKind: capi.ServiceIntentions, + consulPrereq: []capi.ConfigEntry{ + &capi.ServiceConfigEntry{ + Kind: capi.ServiceDefaults, + Name: "foo", + Protocol: "http", + }, + }, + configEntryResourceWithDeletion: &v1alpha1.ServiceIntentions{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: kubeNS, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{FinalizerName}, + }, + Spec: v1alpha1.ServiceIntentionsSpec{ + Destination: v1alpha1.Destination{ + Name: "foo", + }, + Sources: v1alpha1.SourceIntentions{ + &v1alpha1.SourceIntention{ + Name: "bar", + Action: "allow", + }, + &v1alpha1.SourceIntention{ + Name: "baz", + Permissions: v1alpha1.IntentionPermissions{ + &v1alpha1.IntentionPermission{ + Action: "allow", + HTTP: &v1alpha1.IntentionHTTPPermission{ + PathExact: "/path", + Header: v1alpha1.IntentionHTTPHeaderPermissions{ + v1alpha1.IntentionHTTPHeaderPermission{ + Name: "auth", + Present: true, + }, + }, + Methods: []string{ + "PUT", + "GET", + }, + }, + }, + }, + }, + }, + }, + }, + reconciler: func(client client.Client, consulClient *capi.Client, logger logr.Logger) testReconciler { + return &ServiceIntentionsController{ + Client: client, + Log: logger, + ConfigEntryController: &ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: datacenterName, + }, + } + }, + confirmDelete: func(t *testing.T, cli client.Client, ctx context.Context, name types.NamespacedName) { + svcIntentions := &v1alpha1.ServiceIntentions{} + _ = cli.Get(ctx, name, svcIntentions) + require.Empty(t, svcIntentions.Finalizers()) + }, + }, + } + + for _, c := range cases { + t.Run(c.kubeKind, func(t *testing.T) { + req := require.New(t) + ctx := context.Background() + + s := runtime.NewScheme() + s.AddKnownTypes(v1alpha1.GroupVersion, c.configEntryResourceWithDeletion) + client := fake.NewFakeClientWithScheme(s, c.configEntryResourceWithDeletion) + + consul, err := testutil.NewTestServerConfigT(t, nil) + req.NoError(err) + defer consul.Stop() + + consul.WaitForServiceIntentions(t) + consulClient, err := capi.NewClient(&capi.Config{ + Address: consul.HTTPAddr, + }) + req.NoError(err) + + // Create any prereqs. + for _, configEntry := range c.consulPrereq { + written, _, err := consulClient.ConfigEntries().Set(configEntry, nil) + req.NoError(err) + req.True(written) + } + + // We haven't run reconcile yet so we must create the config entry + // in Consul ourselves. + { + // Create the resource with different datacenter on metadata + written, _, err := consulClient.ConfigEntries().Set(c.configEntryResourceWithDeletion.ToConsul("different-datacenter"), nil) + req.NoError(err) + req.True(written) + } + + // Now run reconcile. It's marked for deletion so this should delete the kubernetes resource + // but not the consul config entry. + { + namespacedName := types.NamespacedName{ + Namespace: kubeNS, + Name: c.configEntryResourceWithDeletion.KubernetesName(), + } + r := c.reconciler(client, consulClient, logrtest.TestLogger{T: t}) + resp, err := r.Reconcile(ctrl.Request{ + NamespacedName: namespacedName, + }) + req.NoError(err) + req.False(resp.Requeue) + + entry, _, err := consulClient.ConfigEntries().Get(c.consulKind, c.configEntryResourceWithDeletion.ConsulName(), nil) + req.NoError(err) + req.Equal(entry.GetMeta()[common.DatacenterKey], "different-datacenter") + + // Check that the resource is deleted from cluster. + c.confirmDelete(t, client, ctx, namespacedName) + } + }) + } +} diff --git a/controller/proxydefaults_controller.go b/controller/proxydefaults_controller.go new file mode 100644 index 0000000000..5e0ed58bc1 --- /dev/null +++ b/controller/proxydefaults_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" +) + +// ProxyDefaultsController reconciles a ProxyDefaults object +type ProxyDefaultsController struct { + client.Client + Log logr.Logger + Scheme *runtime.Scheme + ConfigEntryController *ConfigEntryController +} + +// +kubebuilder:rbac:groups=consul.hashicorp.com,resources=proxydefaults,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=consul.hashicorp.com,resources=proxydefaults/status,verbs=get;update;patch + +func (r *ProxyDefaultsController) Reconcile(req ctrl.Request) (ctrl.Result, error) { + return r.ConfigEntryController.ReconcileEntry(r, req, &consulv1alpha1.ProxyDefaults{}) +} + +func (r *ProxyDefaultsController) Logger(name types.NamespacedName) logr.Logger { + return r.Log.WithValues("request", name) +} + +func (r *ProxyDefaultsController) UpdateStatus(ctx context.Context, obj runtime.Object, opts ...client.UpdateOption) error { + return r.Status().Update(ctx, obj, opts...) +} + +func (r *ProxyDefaultsController) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&consulv1alpha1.ProxyDefaults{}). + Complete(r) +} diff --git a/controller/servicedefaults_controller.go b/controller/servicedefaults_controller.go new file mode 100644 index 0000000000..b2dd835f8b --- /dev/null +++ b/controller/servicedefaults_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" +) + +// ServiceDefaultsController is the controller for ServiceDefaults resources. +type ServiceDefaultsController struct { + client.Client + Log logr.Logger + Scheme *runtime.Scheme + ConfigEntryController *ConfigEntryController +} + +// +kubebuilder:rbac:groups=consul.hashicorp.com,resources=servicedefaults,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=consul.hashicorp.com,resources=servicedefaults/status,verbs=get;update;patch + +func (r *ServiceDefaultsController) Reconcile(req ctrl.Request) (ctrl.Result, error) { + return r.ConfigEntryController.ReconcileEntry(r, req, &consulv1alpha1.ServiceDefaults{}) +} + +func (r *ServiceDefaultsController) Logger(name types.NamespacedName) logr.Logger { + return r.Log.WithValues("request", name) +} + +func (r *ServiceDefaultsController) UpdateStatus(ctx context.Context, obj runtime.Object, opts ...client.UpdateOption) error { + return r.Status().Update(ctx, obj, opts...) +} + +func (r *ServiceDefaultsController) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&consulv1alpha1.ServiceDefaults{}). + Complete(r) +} diff --git a/controller/serviceintentions_controller.go b/controller/serviceintentions_controller.go new file mode 100644 index 0000000000..5eb6ea8599 --- /dev/null +++ b/controller/serviceintentions_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" +) + +// ServiceIntentionsController reconciles a ServiceIntentions object +type ServiceIntentionsController struct { + client.Client + Log logr.Logger + Scheme *runtime.Scheme + ConfigEntryController *ConfigEntryController +} + +// +kubebuilder:rbac:groups=consul.hashicorp.com,resources=serviceintentions,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=consul.hashicorp.com,resources=serviceintentions/status,verbs=get;update;patch + +func (r *ServiceIntentionsController) Reconcile(req ctrl.Request) (ctrl.Result, error) { + return r.ConfigEntryController.ReconcileEntry(r, req, &consulv1alpha1.ServiceIntentions{}) +} + +func (r *ServiceIntentionsController) Logger(name types.NamespacedName) logr.Logger { + return r.Log.WithValues("request", name) +} + +func (r *ServiceIntentionsController) UpdateStatus(ctx context.Context, obj runtime.Object, opts ...client.UpdateOption) error { + return r.Status().Update(ctx, obj, opts...) +} + +func (r *ServiceIntentionsController) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&consulv1alpha1.ServiceIntentions{}). + Complete(r) +} diff --git a/controller/serviceresolver_controller.go b/controller/serviceresolver_controller.go new file mode 100644 index 0000000000..8f95e0f5f4 --- /dev/null +++ b/controller/serviceresolver_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" +) + +// ServiceResolverController is the controller for ServiceResolver resources. +type ServiceResolverController struct { + client.Client + Log logr.Logger + Scheme *runtime.Scheme + ConfigEntryController *ConfigEntryController +} + +// +kubebuilder:rbac:groups=consul.hashicorp.com,resources=serviceresolvers,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=consul.hashicorp.com,resources=serviceresolvers/status,verbs=get;update;patch + +func (r *ServiceResolverController) Reconcile(req ctrl.Request) (ctrl.Result, error) { + return r.ConfigEntryController.ReconcileEntry(r, req, &consulv1alpha1.ServiceResolver{}) +} + +func (r *ServiceResolverController) Logger(name types.NamespacedName) logr.Logger { + return r.Log.WithValues("request", name) +} + +func (r *ServiceResolverController) UpdateStatus(ctx context.Context, obj runtime.Object, opts ...client.UpdateOption) error { + return r.Status().Update(ctx, obj, opts...) +} + +func (r *ServiceResolverController) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&consulv1alpha1.ServiceResolver{}). + Complete(r) +} diff --git a/controller/servicerouter_controller.go b/controller/servicerouter_controller.go new file mode 100644 index 0000000000..fb6eb2addb --- /dev/null +++ b/controller/servicerouter_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" +) + +// ServiceRouterController is the controller for ServiceRouter resources. +type ServiceRouterController struct { + client.Client + Log logr.Logger + Scheme *runtime.Scheme + ConfigEntryController *ConfigEntryController +} + +// +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 *ServiceRouterController) Reconcile(req ctrl.Request) (ctrl.Result, error) { + return r.ConfigEntryController.ReconcileEntry(r, req, &consulv1alpha1.ServiceRouter{}) +} + +func (r *ServiceRouterController) Logger(name types.NamespacedName) logr.Logger { + return r.Log.WithValues("request", name) +} + +func (r *ServiceRouterController) UpdateStatus(ctx context.Context, obj runtime.Object, opts ...client.UpdateOption) error { + return r.Status().Update(ctx, obj, opts...) +} + +func (r *ServiceRouterController) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&consulv1alpha1.ServiceRouter{}). + Complete(r) +} diff --git a/controller/servicesplitter_controller.go b/controller/servicesplitter_controller.go new file mode 100644 index 0000000000..16a14524d2 --- /dev/null +++ b/controller/servicesplitter_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" +) + +// ServiceSplitterReconciler reconciles a ServiceSplitter object +type ServiceSplitterController struct { + client.Client + Log logr.Logger + Scheme *runtime.Scheme + ConfigEntryController *ConfigEntryController +} + +// +kubebuilder:rbac:groups=consul.hashicorp.com,resources=servicesplitters,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=consul.hashicorp.com,resources=servicesplitters/status,verbs=get;update;patch + +func (r *ServiceSplitterController) Reconcile(req ctrl.Request) (ctrl.Result, error) { + return r.ConfigEntryController.ReconcileEntry(r, req, &consulv1alpha1.ServiceSplitter{}) +} + +func (r *ServiceSplitterController) Logger(name types.NamespacedName) logr.Logger { + return r.Log.WithValues("request", name) +} + +func (r *ServiceSplitterController) UpdateStatus(ctx context.Context, obj runtime.Object, opts ...client.UpdateOption) error { + return r.Status().Update(ctx, obj, opts...) +} + +func (r *ServiceSplitterController) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&consulv1alpha1.ServiceSplitter{}). + Complete(r) +} diff --git a/go.mod b/go.mod index 777b006f24..44866cca8b 100644 --- a/go.mod +++ b/go.mod @@ -1,35 +1,50 @@ module github.com/hashicorp/consul-k8s require ( - github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878 // indirect + github.com/armon/go-metrics v0.3.4 // indirect github.com/cenkalti/backoff v2.1.1+incompatible github.com/deckarep/golang-set v1.7.1 github.com/digitalocean/godo v1.10.0 // indirect - github.com/hashicorp/consul/api v1.6.0 + github.com/go-logr/logr v0.1.0 + github.com/google/go-cmp v0.4.0 + github.com/google/go-querystring v1.0.0 // indirect + github.com/hashicorp/consul/api v1.4.1-0.20201007080954-aa0f5ff839c5 github.com/hashicorp/consul/sdk v0.6.0 github.com/hashicorp/go-discover v0.0.0-20200812215701-c4b85f6ed31f github.com/hashicorp/go-hclog v0.12.0 - github.com/hashicorp/go-immutable-radix v1.1.0 // indirect + github.com/hashicorp/go-immutable-radix v1.2.0 // indirect github.com/hashicorp/go-msgpack v0.5.5 // indirect github.com/hashicorp/go-multierror v1.1.0 github.com/hashicorp/go-sockaddr v1.0.2 // indirect - github.com/hashicorp/golang-lru v0.5.3 // indirect - github.com/imdario/mergo v0.3.8 // indirect + github.com/hashicorp/go-uuid v1.0.2 // indirect + github.com/hashicorp/serf v0.9.3 + github.com/joyent/triton-go v1.7.1-0.20200416154420-6801d15b779f // indirect + github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect github.com/kr/text v0.1.0 github.com/mattbaird/jsonpatch v0.0.0-20171005235357-81af80346b1a github.com/mitchellh/cli v1.1.0 github.com/mitchellh/go-homedir v1.1.0 - github.com/onsi/gomega v1.8.1 // indirect + github.com/mitchellh/go-testing-interface v1.14.0 // indirect + github.com/mitchellh/mapstructure v1.3.3 // indirect github.com/radovskyb/watcher v1.0.2 - github.com/stretchr/testify v1.4.0 + github.com/stretchr/testify v1.5.1 + go.opencensus.io v0.22.0 // indirect + go.uber.org/zap v1.10.0 golang.org/x/net v0.0.0-20200625001655-4c5254603344 // indirect golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6 // indirect golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 // indirect - golang.org/x/time v0.0.0-20191024005414-555d28b269f0 // indirect + golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae // indirect + golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect - k8s.io/api v0.18.2 - k8s.io/apimachinery v0.18.2 - k8s.io/client-go v0.18.2 + google.golang.org/api v0.9.0 // indirect + google.golang.org/appengine v1.6.0 // indirect + k8s.io/api v0.18.6 + k8s.io/apimachinery v0.18.6 + k8s.io/client-go v0.18.6 + k8s.io/klog/v2 v2.0.0 + sigs.k8s.io/controller-runtime v0.6.3 ) +replace github.com/hashicorp/consul/sdk v0.6.0 => github.com/hashicorp/consul/sdk v0.4.1-0.20201006182405-a2a8e9c7839a + go 1.14 diff --git a/go.sum b/go.sum index 08cdb22b2e..b07a5361a3 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,7 @@ cloud.google.com/go v0.38.0 h1:ROfEUZz+Gh5pa62DJWXSaonyu3StP6EA6lPEXPI6mCo= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= github.com/Azure/azure-sdk-for-go v44.0.0+incompatible h1:e82Yv2HNpS0kuyeCrV29OPKvEiqfs2/uJHic3/3iKdg= github.com/Azure/azure-sdk-for-go v44.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= @@ -35,35 +36,66 @@ github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbt github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/DataDog/datadog-go v2.2.0+incompatible h1:V5BKkxACZLjzHjSgBbr2gvLA2Ae49yhc6CSY7MLy5k4= -github.com/DataDog/datadog-go v2.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/abdullin/seq v0.0.0-20160510034733-d5467c17e7af h1:DBNMBMuMiWYu0b+8KMJuWmfCkcxl09JwdlqwDZZ6U14= github.com/abdullin/seq v0.0.0-20160510034733-d5467c17e7af/go.mod h1:5Jv4cbFiHJMsVxt52+i0Ha45fjshj6wxYr1r19tB9bw= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e h1:QEF07wC0T1rKkctt1RINW/+RMTVmiwxETico2l3gxJA= +github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878 h1:EFSB7Zo9Eg91v7MJPVsifUysc/wPdN+NOnVe6bWbdBM= -github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878/go.mod h1:3AMJUQhVx52RsWOnlkpikZr01T/yAVN2gn0861vByNg= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310 h1:BUAU3CGlLvorLI26FmByPp2eC2qla6E1Tw+scpcg/to= +github.com/armon/go-metrics v0.3.4 h1:Xqf+7f2Vhl9tsqDYmXhnXInUdcrtgpRNpIA15/uldSc= +github.com/armon/go-metrics v0.3.4/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/aws/aws-sdk-go v1.25.41 h1:/hj7nZ0586wFqpwjNpzWiUTwtaMgxAZNZKHay80MdXw= github.com/aws/aws-sdk-go v1.25.41/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/cenkalti/backoff v2.1.1+incompatible h1:tKJnvO2kl0zmb/jA5UKAt4VoEVw1qxKWjE/Bpp46npY= github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= -github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible h1:C29Ae4G5GtYyYMm1aztcyj/J5ckgJm2zwdDajFbx1NY= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= -github.com/circonus-labs/circonusllhist v0.1.3 h1:TJH+oke8D16535+jHExHj4nQvzlZrj7ug5D7I/orNUA= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/coreos/pkg v0.0.0-20180108230652-97fdf19511ea/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -73,6 +105,7 @@ github.com/denverdino/aliyungo v0.0.0-20170926055100-d3308649c661 h1:lrWnAyy/F72 github.com/denverdino/aliyungo v0.0.0-20170926055100-d3308649c661/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/digitalocean/godo v1.7.5/go.mod h1:h6faOIcZ8lWIwNQ+DN7b3CgX4Kwby5T+nbpNqkUIozU= github.com/digitalocean/godo v1.10.0 h1:uW1/FcvZE/hoixnJcnlmIUvTVNdZCLjRLzmDtRi1xXY= github.com/digitalocean/godo v1.10.0/go.mod h1:h6faOIcZ8lWIwNQ+DN7b3CgX4Kwby5T+nbpNqkUIozU= @@ -80,65 +113,147 @@ github.com/dimchansky/utfbom v1.1.0 h1:FcM3g+nofKgUteL8dm/UpdRXNC9KmADgTpLKsu0TR github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8= github.com/dnaeon/go-vcr v1.0.1 h1:r8L/HqC0Hje5AXMu1ooW8oyQyOFv4GxqpL0nRP7SLLY= github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= +github.com/docker/docker v0.7.3-0.20190327010347-be7ac8be2ae0/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= -github.com/evanphx/json-patch v4.2.0+incompatible h1:fUDGZCv/7iAN7u0puUVhvKCcsR6vRfwrJatElLBEf0I= +github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= +github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v4.9.0+incompatible h1:kLcOMZeuLAJvL2BPWLMIj5oaZQobrkAqrL+WFZwQses= +github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= -github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= +github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v0.1.0 h1:M1Tv3VzNlEHg6uyACnRdtrploV2P7wZqH8BoQMtz0cg= github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= +github.com/go-logr/zapr v0.1.0 h1:h+WVe9j6HAA01niTJPA/kKH0i7e0rLZBCwauQFcRE54= +github.com/go-logr/zapr v0.1.0/go.mod h1:tabnROwaDl0UNxkVeFRbY8bwB37GwRv0P8lg6aAiEnk= +github.com/go-openapi/analysis v0.0.0-20180825180245-b006789cd277/go.mod h1:k70tL6pCuVxPJOHXQ+wIac1FUrvNkHolPie/cLEU6hI= +github.com/go-openapi/analysis v0.17.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= +github.com/go-openapi/analysis v0.18.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= +github.com/go-openapi/analysis v0.19.2/go.mod h1:3P1osvZa9jKjb8ed2TPng3f0i/UY9snX6gxi44djMjk= +github.com/go-openapi/analysis v0.19.5/go.mod h1:hkEAkxagaIvIP7VTn8ygJNkd4kAYON2rCu0v0ObL0AU= +github.com/go-openapi/errors v0.17.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= +github.com/go-openapi/errors v0.18.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= +github.com/go-openapi/errors v0.19.2/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94= github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= +github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= +github.com/go-openapi/jsonpointer v0.18.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= +github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg= +github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= +github.com/go-openapi/jsonreference v0.18.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= +github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= +github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= +github.com/go-openapi/loads v0.17.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.18.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.19.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.19.2/go.mod h1:QAskZPMX5V0C2gvfkGZzJlINuP7Hx/4+ix5jWFxsNPs= +github.com/go-openapi/loads v0.19.4/go.mod h1:zZVHonKd8DXyxyw4yfnVjPzBjIQcLt0CCsn0N0ZrQsk= +github.com/go-openapi/runtime v0.0.0-20180920151709-4f900dc2ade9/go.mod h1:6v9a6LTXWQCdL8k1AO3cvqx5OtZY/Y9wKTgaoP6YRfA= +github.com/go-openapi/runtime v0.19.0/go.mod h1:OwNfisksmmaZse4+gpV3Ne9AyMOlP1lt4sK4FXt0O64= +github.com/go-openapi/runtime v0.19.4/go.mod h1:X277bwSUBxVlCYR3r7xgZZGKVvBd/29gLDlFGtJ8NL4= github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc= +github.com/go-openapi/spec v0.17.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= +github.com/go-openapi/spec v0.18.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= +github.com/go-openapi/spec v0.19.2/go.mod h1:sCxk3jxKgioEJikev4fgkNmwS+3kuYdJtcsZsD5zxMY= +github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= +github.com/go-openapi/strfmt v0.17.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= +github.com/go-openapi/strfmt v0.18.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= +github.com/go-openapi/strfmt v0.19.0/go.mod h1:+uW+93UVvGGq2qGaZxdDeJqSAqBqBdl+ZPMF/cC8nDY= +github.com/go-openapi/strfmt v0.19.3/go.mod h1:0yX7dbo8mKIvc3XSKp7MNfxw4JytCfCD6+bY1AVL9LU= github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I= +github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= +github.com/go-openapi/swag v0.18.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= +github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/validate v0.18.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4= +github.com/go-openapi/validate v0.19.2/go.mod h1:1tRCw7m3jtI8eNWEEliiAqUIcBztB2KDnRCRMUi7GTA= +github.com/go-openapi/validate v0.19.5/go.mod h1:8DJv2CVJQ6kGNpFW6eV9N3JviE1C85nY1c2z52x1Gk4= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls= github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903 h1:LbsanbbD6LieFkXbj9YNNBupiGHJgFeLpO0j0Fza1h8= github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef h1:veQD95Isof8w9/WXiA+pa3tz3fJXkt5B7QaRBrM62gk= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c h1:964Od4U6p2jUkFxvCydnIczKteheJEzHRToSGK3Bnlw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135 h1:zLTLjkaOFEFIOxY5BWLFLwh+cL8vOBW4XJ2aqLE/Tf0= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= -github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= github.com/googleapis/gnostic v0.1.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= -github.com/googleapis/gnostic v0.2.0 h1:l6N3VoaVzTncYYW+9yOz2LJJammFZGBO13sqgEhpy9g= github.com/googleapis/gnostic v0.2.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/googleapis/gnostic v0.3.1 h1:WeAefnSUHlBb0iJKwxFDZdbfGwkd7xRNuV+IpXMJhYk= +github.com/googleapis/gnostic v0.3.1/go.mod h1:on+2t9HRStVgn95RSsFWFz+6Q0Snyqv1awfrALZdbtU= github.com/gophercloud/gophercloud v0.1.0 h1:P/nh25+rzXouhytV2pUHBb65fnds26Ghl8/391+sT5o= github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8= +github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/hashicorp/consul/api v1.6.0 h1:SZB2hQW8AcTOpfDmiVblQbijxzsRuiyy0JpHfabvHio= -github.com/hashicorp/consul/api v1.6.0/go.mod h1:1NSuaUUkFaJzMasbfq/11wKYWSR67Xn6r2DXKhuDNFg= -github.com/hashicorp/consul/sdk v0.6.0 h1:FfhMEkwvQl57CildXJyGHnwGGM4HMODGyfjGwNM1Vdw= -github.com/hashicorp/consul/sdk v0.6.0/go.mod h1:fY08Y9z5SvJqevyZNy6WWPXiG3KwBPAvlcdx16zZ0fM= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/consul/api v1.4.1-0.20201007080954-aa0f5ff839c5 h1:mHgaDaPdf0z3UG3G2UpCINFMRKhzi1DLNoOp6sIQWnU= +github.com/hashicorp/consul/api v1.4.1-0.20201007080954-aa0f5ff839c5/go.mod h1:1NSuaUUkFaJzMasbfq/11wKYWSR67Xn6r2DXKhuDNFg= +github.com/hashicorp/consul/sdk v0.4.1-0.20201006182405-a2a8e9c7839a h1:yclqizoDCodLeiAUg1Siaodz3hvIBxzH8A2GnjY74EU= +github.com/hashicorp/consul/sdk v0.4.1-0.20201006182405-a2a8e9c7839a/go.mod h1:fY08Y9z5SvJqevyZNy6WWPXiG3KwBPAvlcdx16zZ0fM= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= @@ -148,14 +263,12 @@ github.com/hashicorp/go-discover v0.0.0-20200812215701-c4b85f6ed31f h1:7WFMVeuJQ github.com/hashicorp/go-discover v0.0.0-20200812215701-c4b85f6ed31f/go.mod h1:D4eo8/CN92vm9/9UDG+ldX1/fMFa4kpl8qzyTolus8o= github.com/hashicorp/go-hclog v0.12.0 h1:d4QkX8FRTYaKaCZBoXYY8zJX2BXjWxurN/GA2tkrmZM= github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= -github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-immutable-radix v1.1.0 h1:vN9wG1D6KG6YHRTWr8512cxGOVgTMEfgEdSj/hr8MPc= -github.com/hashicorp/go-immutable-radix v1.1.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.2.0 h1:l6UW37iCXwZkZoAbEYnptSHVE/cQ5bOTPYG5W3vf9+8= +github.com/hashicorp/go-immutable-radix v1.2.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI= github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= @@ -165,16 +278,16 @@ github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR3 github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= -github.com/hashicorp/go-syslog v1.0.0 h1:KaodqZuhUoZereWVIYmpUgZysurB1kBLX2j0MwMrUAE= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.3 h1:YPkqC67at8FYaadspW/6uE0COsBxS2656RLEr8Bppgk= -github.com/hashicorp/golang-lru v0.5.3/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.1 h1:XFSOubp8KWB+Jd2PDyaX5xUd5bhSP/+pTDZVDMzZJM8= github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= @@ -184,64 +297,87 @@ github.com/hashicorp/serf v0.9.3 h1:AVF6JDQQens6nMHT9OGERBvK0f8rPrAGILnsKLr6lzM= github.com/hashicorp/serf v0.9.3/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= github.com/hashicorp/vic v1.5.1-0.20190403131502-bbfe86ec9443 h1:O/pT5C1Q3mVXMyuqg7yuAWUg/jMZR1/0QTzTRdNR6Uw= github.com/hashicorp/vic v1.5.1-0.20190403131502-bbfe86ec9443/go.mod h1:bEpDU35nTu0ey1EXjwNwPjI9xErAsoOCmcMb9GKvyxo= -github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= -github.com/imdario/mergo v0.3.8 h1:CGgOkSJeqMRmt0D9XLWExdT4m4F1vd3FV3VPt+0VxkQ= -github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.9 h1:UauaLniWCFHWd+Jp9oCEkTBj8VO/9DKg3PV3VCNMDIg= +github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ= +github.com/jackc/pgx v3.3.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= github.com/jarcoal/httpmock v0.0.0-20180424175123-9c70cfe4a1da h1:FjHUJJ7oBW4G/9j1KzlHaXL09LyMVM9rupS39lncbXk= github.com/jarcoal/httpmock v0.0.0-20180424175123-9c70cfe4a1da/go.mod h1:ks+b9deReOc7jgqp+e7LuFiCBH6Rm5hL32cLcEAArb4= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= -github.com/joyent/triton-go v0.0.0-20180628001255-830d2b111e62 h1:JHCT6xuyPUrbbgAPE/3dqlvUKzRHMNuTBKKUb6OeR/k= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/joyent/triton-go v0.0.0-20180628001255-830d2b111e62/go.mod h1:U+RSyWxWd04xTqnuOQxnai7XGS2PrPY2cfGoDKtMHjA= +github.com/joyent/triton-go v1.7.1-0.20200416154420-6801d15b779f h1:ENpDacvnr8faw5ugQmEF1QYk+f/Y9lXFvuYmRxykago= +github.com/joyent/triton-go v1.7.1-0.20200416154420-6801d15b779f/go.mod h1:KDSfL7qe5ZfQqvlDMkVjCztbmcpp/c8M77vhQP8ZPvk= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.8 h1:QiWkFLKq0T7mpzwOTu6BzNDbfTE8OLrYhVKYMLF46Ok= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/linode/linodego v0.7.1 h1:4WZmMpSA2NRwlPZcc0+4Gyn7rr99Evk9bnr0B3gXRKE= github.com/linode/linodego v0.7.1/go.mod h1:ga11n3ivecUrPCHN0rANxKmfWBJVkOXfLMZinAbj2sY= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= github.com/mattbaird/jsonpatch v0.0.0-20171005235357-81af80346b1a h1:+J2gw7Bw77w/fbK7wnNJJDKmw1IbWft2Ul5BzrG1Qm8= github.com/mattbaird/jsonpatch v0.0.0-20171005235357-81af80346b1a/go.mod h1:M1qoD/MqPgTZIk0EWKB38wE28ACRfVcn+cU08jyArI0= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/miekg/dns v1.0.14 h1:9jZdLNd/P4+SfEJ0TNyxYpsK8N4GtfylBLqtbYN1sbA= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26 h1:gPxPSwALAeHJSjarOs00QjVdV9QoBvc1D2ujQUr5BzU= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= -github.com/mitchellh/cli v1.0.0 h1:iGBIsUe3+HZ/AD/Vd7DErOt5sU9fa8Uj7A2s1aggv1Y= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/cli v1.1.0 h1:tEElEatulEHDeedTxwckzyYMA5c86fbmNIUL1hBIiTg= github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v1.0.0 h1:fzU/JVNcaqHQEcVFAKeR41fkiLdIPrefOvVG1VZ96U0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/go-testing-interface v1.14.0 h1:/x0XQ6h+3U3nAyk1yx+bHPURrKa9sVVvYbuqZ7pIAtI= +github.com/mitchellh/go-testing-interface v1.14.0/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.3.3 h1:SzB1nHZ2Xi+17FP0zVQBHIZqvwRN9408fJO8h+eeNA8= +github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -249,69 +385,150 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nicolai86/scaleway-sdk v1.10.2-0.20180628010248-798f60e20bb2 h1:BQ1HW7hr4IVovMwWg0E0PYcyW8CzqDcVmaew9cujU4s= github.com/nicolai86/scaleway-sdk v1.10.2-0.20180628010248-798f60e20bb2/go.mod h1:TLb2Sg7HQcgGdloNxkrmtgDNR9uVYF3lfdFIN4Ro6Sk= +github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/olekukonko/tablewriter v0.0.0-20180130162743-b8a9be070da4/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.11.0 h1:JAKSXpt1YjtLA7YpPiqO9ss6sNXEsPfSGdwN0UHqzrw= github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1 h1:mFwc4LvZ0xpSvDZ3E+k8Yte0hLOMxXUlP+yXtJqkYfQ= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.8.1 h1:C5Dqfs/LeauYDX0jJXIe2SWmwCbGzx9yF8C8xy3Lh34= -github.com/onsi/gomega v1.8.1/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/packethost/packngo v0.1.1-0.20180711074735-b9cb5096f54c h1:vwpFWvAO8DeIZfFeqASzZfsxuWPno9ncAebBEP0N3uE= github.com/packethost/packngo v0.1.1-0.20180711074735-b9cb5096f54c/go.mod h1:otzZQXgoO96RTzDB/Hycg0qZcXZsWJGJRSXbmEIJ+4M= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posener/complete v1.1.1 h1:ccV59UEOTzVDnDUEFdT95ZzHVZ+5+158q8+SJb2QV5w= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= -github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= +github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.4.0 h1:YVIb/fVcOTMSqtqZWSKnHpSLBxu8DKgxq8z6RuBZwqI= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1 h1:KOMtN28tlbam3/7ZKEYKHhKoJZYYj3gMH4uc62x7X7U= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.0.11 h1:DhHlBtkHWPYi8O2y31JkK0TF+DGM+51OopZjH/Ia5qI= +github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/radovskyb/watcher v1.0.2 h1:9L5TsZUbo1nKhQEQPtICVc+x9UZQ6VPdBepLHyGw/bQ= github.com/radovskyb/watcher v1.0.2/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg= github.com/renier/xmlrpc v0.0.0-20170708154548-ce4a1a486c03 h1:Wdi9nwnhFNAlseAOekn6B5G/+GMtks9UKbvRU/CMM/o= github.com/renier/xmlrpc v0.0.0-20170708154548-ce4a1a486c03/go.mod h1:gRAiPF5C5Nd0eyyRdqIu9qTiFSoZzpTq727b5B8fkkU= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rs/zerolog v1.4.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/sean-/conswriter v0.0.0-20180208195008-f5ae3917a627/go.mod h1:7zjs06qF79/FKAJpBvFx3P8Ww4UTIMAe+lpNXDHziac= +github.com/sean-/pager v0.0.0-20180208200047-666be9bf53b5/go.mod h1:BeybITEsBEg6qbIiqJ6/Bqeq25bCLbL7YFmpaFfJDuM= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sirupsen/logrus v1.0.6 h1:hcP1GmhGigz/O7h1WVUM5KklBp1JoNS9FggWKdj/j3s= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/softlayer/softlayer-go v0.0.0-20180806151055-260589d94c7d h1:bVQRCxQvfjNUeRqaY/uT0tFuvuFY0ulgnczuR684Xic= github.com/softlayer/softlayer-go v0.0.0-20180806151055-260589d94c7d/go.mod h1:Cw4GTlQccdRGSEf6KiMju767x0NEHE0YIVPJSaXjlsw= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/tencentcloud/tencentcloud-sdk-go v3.0.83+incompatible h1:8uRvJleFpqLsO77WaAh2UrasMOzd8MxXrNj20e7El+Q= github.com/tencentcloud/tencentcloud-sdk-go v3.0.83+incompatible/go.mod h1:0PfYow01SHPMhKY31xa+EFz2RStxIqj6JFAJS+IkCi4= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw= github.com/vmware/govmomi v0.18.0 h1:f7QxSmP7meCtoAmiKZogvVbLInT+CZx6Px6K5rYsJZo= github.com/vmware/govmomi v0.18.0/go.mod h1:URlwyTFZX72RmxtxuaFL2Uj3fD1JTvZdx59bHWk6aFU= -go.opencensus.io v0.21.0 h1:mU6zScU4U1YAFPHEHYk+3JC4SY7JxgkqS10ZOSyksNg= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= +go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.mongodb.org/mongo-driver v1.1.1/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.mongodb.org/mongo-driver v1.1.2/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0 h1:C9hSCOW830chIVkdja34wa6Ky+IzWllkUinR+BtRZd4= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0 h1:cxzIVoETapQEqDhQu3QfnvXAV4AlzcvUCxkVUFw3+EU= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.10.0 h1:ORx85nbTijNz8ljznvCMR1ZBIPKFn3jQrag10X2AsuM= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190617133340-57b3e21c3d56/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= @@ -320,21 +537,31 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190320064053-1272bf9dcd53/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191004110552-13f9640d40b9 h1:rjwSpXsdiK0dV8/Naq3kAw9ymfAeJIyd0upUIElB+lI= golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344 h1:vGXIOMxbNfDTk/aXCmfdLgkrSV+Z2tcbze+pEc3v5W4= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -346,102 +573,168 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 h1:qwRHBd0NqMbJxfbotnDhm2ByMI1Shq4Y6oRJo21SGJA= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190321052220-f7bb7a8bee54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190515120540-06a5c4944438/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9 h1:1/DFK4b7JH8DmkqhUk48onnSfrPzImPoVxuomtbT2nk= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae h1:Ih9Yo4hSPImZOpfGuA4bR/ORKTAbhZo2AbWNRCnevdo= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI= +golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190617190820-da514acc4774/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= +golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0 h1:KKgc1aqhV8wDPbDzlDtpvyjZFY3vjz85FP7p4wcQUyI= +gomodules.xyz/jsonpatch/v2 v2.0.1 h1:xyiBuvkD2g5n7cYzx6u2sxQvsAy4QJsZFCzGVdzOXZ0= +gomodules.xyz/jsonpatch/v2 v2.0.1/go.mod h1:IhYNNY4jnS53ZnfE4PAmpKtDpTCj1JFXc+3mwe7XcUU= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.9.0 h1:jbyannxz0XFD3zdjgrSUsaJbgpH4eTrkdhRChkHPfO8= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0 h1:KxkO13IPW4Lslp2bz+KHP2E3gtFlrIGNThxkZQ3g+4c= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.0 h1:Tfd7cKwKbFRsI8RMAD3oqqw7JPFRrvFlOsfbgVkjOOw= +google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7 h1:ZUjXAXmrAyrmmCPHgCA/vChHcpsX27MZ3yBonD/z1KE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/grpc v1.19.0 h1:cfg4PD8YEdSFnm7qLV4++93WcmhH2nIUhMjhdCvl3j8= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55 h1:gSJIx1SDwno+2ElGhA4+qG2zF97qiUzTM+rQ0klBOcE= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -gopkg.in/airbrake/gobrake.v2 v2.0.9 h1:7z2uVWwn7oVeeugY1DtlPAy5H+KYgB1KeKTnqjNatLo= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.26.0 h1:2dTRdpdFEEhJYQD8EMLB61nnrzSCTbG38PhqdhvOltg= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2 h1:OAj3g0cR6Dx/R07QgQe8wkA9RNjB2u4i700xBkIT4e0= gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/resty.v1 v1.12.0 h1:CuXP0Pjfw9rOuY6EP+UvtNvt5DSqHpIxILZKT/quCZI= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -k8s.io/api v0.18.2 h1:wG5g5ZmSVgm5B+eHMIbI9EGATS2L8Z72rda19RIEgY8= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= k8s.io/api v0.18.2/go.mod h1:SJCWI7OLzhZSvbY7U8zwNl9UA4o1fizoug34OV/2r78= -k8s.io/apimachinery v0.18.2 h1:44CmtbmkzVDAhCpRVSiP2R5PPrC2RtlIv/MoB8xpdRA= +k8s.io/api v0.18.6 h1:osqrAXbOQjkKIWDTjrqxWQ3w0GkKb1KA1XkUGHHYpeE= +k8s.io/api v0.18.6/go.mod h1:eeyxr+cwCjMdLAmr2W3RyDI0VvTawSg/3RFFBEnmZGI= +k8s.io/apiextensions-apiserver v0.18.6 h1:vDlk7cyFsDyfwn2rNAO2DbmUbvXy5yT5GE3rrqOzaMo= +k8s.io/apiextensions-apiserver v0.18.6/go.mod h1:lv89S7fUysXjLZO7ke783xOwVTm6lKizADfvUM/SS/M= k8s.io/apimachinery v0.18.2/go.mod h1:9SnR/e11v5IbyPCGbvJViimtJ0SwHG4nfZFjU77ftcA= -k8s.io/client-go v0.18.2 h1:aLB0iaD4nmwh7arT2wIn+lMnAq7OswjaejkQ8p9bBYE= +k8s.io/apimachinery v0.18.6 h1:RtFHnfGNfd1N0LeSrKCUznz5xtUP1elRGvHJbL3Ntag= +k8s.io/apimachinery v0.18.6/go.mod h1:OaXp26zu/5J7p0f92ASynJa1pZo06YlV9fG7BoWbCko= +k8s.io/apiserver v0.18.6/go.mod h1:Zt2XvTHuaZjBz6EFYzpp+X4hTmgWGy8AthNVnTdm3Wg= k8s.io/client-go v0.18.2/go.mod h1:Xcm5wVGXX9HAA2JJ2sSBUn3tCJ+4SVlCbl2MNNv+CIU= +k8s.io/client-go v0.18.6 h1:I+oWqJbibLSGsZj8Xs8F0aWVXJVIoUHWaaJV3kUN/Zw= +k8s.io/client-go v0.18.6/go.mod h1:/fwtGLjYMS1MaM5oi+eXhKwG+1UHidUEXRh6cNsdO0Q= +k8s.io/code-generator v0.18.6/go.mod h1:TgNEVx9hCyPGpdtCWA34olQYLkh3ok9ar7XfSsr8b6c= +k8s.io/component-base v0.18.6/go.mod h1:knSVsibPR5K6EW2XOjEHik6sdU5nCvKMrzMt2D4In14= k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/gengo v0.0.0-20200114144118-36b2048a9120/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8= k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= -k8s.io/kube-openapi v0.0.0-20200121204235-bf4fb3bd569c h1:/KUFqjjqAcY4Us6luF5RDNZ16KJtb49HfR3ZHB9qYXM= +k8s.io/klog/v2 v2.0.0 h1:Foj74zO6RbjjP4hBEKjnYtjjAhGg4jNynUdYF6fJrok= +k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= k8s.io/kube-openapi v0.0.0-20200121204235-bf4fb3bd569c/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E= -k8s.io/utils v0.0.0-20200324210504-a9aa75ae1b89 h1:d4vVOjXm687F1iLSP2q3lyPPuyvTUt3aVoBpi2DqRsU= +k8s.io/kube-openapi v0.0.0-20200410145947-61e04a5be9a6 h1:Oh3Mzx5pJ+yIumsAD0MOECPVeXsVot0UkiaCGVyfGQY= +k8s.io/kube-openapi v0.0.0-20200410145947-61e04a5be9a6/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E= k8s.io/utils v0.0.0-20200324210504-a9aa75ae1b89/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= +k8s.io/utils v0.0.0-20200603063816-c1c6865ac451 h1:v8ud2Up6QK1lNOKFgiIVrZdMg7MpmSnvtrOieolJKoE= +k8s.io/utils v0.0.0-20200603063816-c1c6865ac451/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.7/go.mod h1:PHgbrJT7lCHcxMU+mDHEm+nx46H4zuuHZkDP6icnhu0= +sigs.k8s.io/controller-runtime v0.6.3 h1:SBbr+inLPEKhvlJtrvDcwIpm+uhDvp63Bl72xYJtoOE= +sigs.k8s.io/controller-runtime v0.6.3/go.mod h1:WlZNXcM0++oyaQt4B7C2lEE5JYRs8vJUzRP4N4JpdAY= sigs.k8s.io/structured-merge-diff/v3 v3.0.0-20200116222232-67a7b8c61874/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw= sigs.k8s.io/structured-merge-diff/v3 v3.0.0 h1:dOmIZBMfhcHS09XZkMyUgkq5trg3/jRyJYFZUiaOp8E= sigs.k8s.io/structured-merge-diff/v3 v3.0.0/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw= diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/helper/cert/notify.go b/helper/cert/notify.go index 543e1f6ffa..201ca34dd5 100644 --- a/helper/cert/notify.go +++ b/helper/cert/notify.go @@ -13,7 +13,7 @@ type Notify struct { // Ch is where the notifications for new bundles are sent. If this // blocks then the notify loop will also be blocked, so downstream // users should process this channel in a timely manner. - Ch chan<- Bundle + Ch chan<- MetaBundle // Source is the source of certificates. Source Source @@ -22,6 +22,17 @@ type Notify struct { ctx context.Context ctxCancel context.CancelFunc doneCh <-chan struct{} + + // WebhookConfigName is the name of the MutatingWebhookConfiguration + // that will be updated with the CA bundle when a new CA is generated. + WebhookConfigName string + // SecretName is the name of the Kubernetes TLS secret that will be + // be created/updated with the leaf certificate and it's private key when + // a new certificate key pair are generated. + SecretName string + // SecretNamespace is the namespace in which the aforementioned secret + // will be created/updated. + SecretNamespace string } // Start starts the notifier. This blocks and should be started in a goroutine. @@ -67,7 +78,12 @@ func (n *Notify) Start(ctx context.Context) { // Send the update, or quit if we were cancelled select { - case n.Ch <- next: + case n.Ch <- MetaBundle{ + Bundle: next, + WebhookConfigName: n.WebhookConfigName, + SecretName: n.SecretName, + SecretNamespace: n.SecretNamespace, + }: case <-ctx.Done(): return } diff --git a/helper/cert/notify_test.go b/helper/cert/notify_test.go index 5a56025958..8813816f86 100644 --- a/helper/cert/notify_test.go +++ b/helper/cert/notify_test.go @@ -15,7 +15,7 @@ func TestNotify(t *testing.T) { source.ExpiryWithin = 2 * time.Second // Create notifier - ch := make(chan Bundle) + ch := make(chan MetaBundle) n := &Notify{Ch: ch, Source: source} defer n.Stop() go n.Start(context.Background()) @@ -25,7 +25,7 @@ func TestNotify(t *testing.T) { case <-time.After(250 * time.Millisecond): t.Fatal("should've received initial bundle") case b := <-ch: - testBundleVerify(t, &b) + testBundleVerify(t, &b.Bundle) } // We should not receive an update for at least one second @@ -36,5 +36,5 @@ func TestNotify(t *testing.T) { } b := <-ch - testBundleVerify(t, &b) + testBundleVerify(t, &b.Bundle) } diff --git a/helper/cert/source.go b/helper/cert/source.go index 7048be5639..dcc4e3640c 100644 --- a/helper/cert/source.go +++ b/helper/cert/source.go @@ -32,6 +32,23 @@ type Bundle struct { CACert []byte // CA cert bundle, optional. } +// MetaBundle is a composition of a certificate bundle with fields indicating +// the name of a MutatingWebhookConfiguration and a Secret that will be updated +// when a new Bundle is available. +type MetaBundle struct { + Bundle + // WebhookConfigName is the name of the MutatingWebhookConfiguration + // that will be updated with the CA bundle when a new CA is generated. + WebhookConfigName string + // SecretName is the name of the Kubernetes TLS secret that will be + // be created/updated with the leaf certificate and it's private key when + // a new certificate key pair are generated. + SecretName string + // SecretNamespace is the namespace in which the aforementioned secret + // will be created/updated. + SecretNamespace string +} + // Equal returns true if the two cert bundles contain equivalent certs. func (b *Bundle) Equal(b2 *Bundle) bool { return reflect.DeepEqual(b, b2) diff --git a/namespaces/namespaces.go b/namespaces/namespaces.go new file mode 100644 index 0000000000..4fffbdbbc7 --- /dev/null +++ b/namespaces/namespaces.go @@ -0,0 +1,61 @@ +// Package namespaces handles interaction with Consul namespaces needed across +// commands. +package namespaces + +import ( + "fmt" + + capi "github.com/hashicorp/consul/api" +) + +// EnsureExists ensures a Consul namespace with name ns exists. If it doesn't, +// it will create it and set crossNSACLPolicy as a policy default. +// Boolean return value indicates if the namespace was created by this call. +func EnsureExists(client *capi.Client, ns string, crossNSAClPolicy string) (bool, error) { + // Check if the Consul namespace exists. + namespaceInfo, _, err := client.Namespaces().Read(ns, nil) + if err != nil { + return false, err + } + if namespaceInfo != nil { + return false, nil + } + + // If not, create it. + var aclConfig capi.NamespaceACLConfig + if crossNSAClPolicy != "" { + // Create the ACLs config for the cross-Consul-namespace + // default policy that needs to be attached + aclConfig = capi.NamespaceACLConfig{ + PolicyDefaults: []capi.ACLLink{ + {Name: crossNSAClPolicy}, + }, + } + } + + consulNamespace := capi.Namespace{ + Name: ns, + Description: "Auto-generated by consul-k8s", + ACLs: &aclConfig, + Meta: map[string]string{"external-source": "kubernetes"}, + } + + _, _, err = client.Namespaces().Create(&consulNamespace, nil) + return true, err +} + +// ConsulNamespace returns the consul namespace that a service should be +// registered in based on the namespace options. It returns an +// empty string if namespaces aren't enabled. +func ConsulNamespace(kubeNS string, enableConsulNamespaces bool, consulDestNS string, enableMirroring bool, mirroringPrefix string) string { + if !enableConsulNamespaces { + return "" + } + + // Mirroring takes precedence. + if enableMirroring { + return fmt.Sprintf("%s%s", mirroringPrefix, kubeNS) + } + + return consulDestNS +} diff --git a/namespaces/namespaces_test.go b/namespaces/namespaces_test.go new file mode 100644 index 0000000000..433da3916e --- /dev/null +++ b/namespaces/namespaces_test.go @@ -0,0 +1,190 @@ +// +build enterprise + +package namespaces + +import ( + "fmt" + "testing" + "time" + + capi "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/sdk/testutil" + "github.com/hashicorp/serf/testutil/retry" + "github.com/stretchr/testify/require" +) + +// Test that if the namespace already exists the function succeeds. +func TestEnsureExists_AlreadyExists(tt *testing.T) { + for _, c := range []struct { + ACLsEnabled bool + }{ + { + ACLsEnabled: true, + }, + { + ACLsEnabled: false, + }, + } { + tt.Run(fmt.Sprintf("acls: %t", c.ACLsEnabled), func(t *testing.T) { + req := require.New(t) + ns := "ns" + masterToken := "master" + + consul, err := testutil.NewTestServerConfigT(t, func(cfg *testutil.TestServerConfig) { + cfg.ACL.Enabled = c.ACLsEnabled + cfg.ACL.DefaultPolicy = "deny" + cfg.ACL.Tokens.Master = masterToken + }) + req.NoError(err) + defer consul.Stop() + consulClient, err := capi.NewClient(&capi.Config{ + Address: consul.HTTPAddr, + Token: masterToken, + }) + req.NoError(err) + + // Pre-create the namespace. + _, _, err = consulClient.Namespaces().Create(&capi.Namespace{ + Name: ns, + }, nil) + req.NoError(err) + + var crossNSPolicy string + if c.ACLsEnabled { + crossNSPolicy = "cross-ns-policy" + } + created, err := EnsureExists(consulClient, ns, crossNSPolicy) + req.NoError(err) + require.False(t, created) + + // Ensure it still exists. + consulNS, _, err := consulClient.Namespaces().Read(ns, nil) + req.NoError(err) + // We can test that it wasn't updated by checking that the cross + // namespace ACL policy wasn't added (if running with acls). + if c.ACLsEnabled { + require.Nil(t, consulNS.ACLs) + } + }) + } +} + +// Test that it creates the namespace if it doesn't exist. +func TestEnsureExists_CreatesNS(tt *testing.T) { + for _, c := range []struct { + ACLsEnabled bool + }{ + { + ACLsEnabled: true, + }, + { + ACLsEnabled: false, + }, + } { + tt.Run(fmt.Sprintf("acls: %t", c.ACLsEnabled), func(t *testing.T) { + req := require.New(t) + ns := "ns" + masterToken := "master" + + consul, err := testutil.NewTestServerConfigT(t, func(cfg *testutil.TestServerConfig) { + cfg.ACL.Enabled = c.ACLsEnabled + cfg.ACL.DefaultPolicy = "deny" + cfg.ACL.Tokens.Master = masterToken + }) + req.NoError(err) + defer consul.Stop() + + consulClient, err := capi.NewClient(&capi.Config{ + Address: consul.HTTPAddr, + Token: masterToken, + }) + req.NoError(err) + + // Need to loop to ensure Consul is up. + timer := &retry.Timer{Timeout: 5 * time.Second, Wait: 500 * time.Millisecond} + retry.RunWith(timer, tt, func(r *retry.R) { + leader, err := consulClient.Status().Leader() + require.NoError(r, err) + require.NotEmpty(r, leader) + }) + + var crossNSPolicy string + if c.ACLsEnabled { + crossNSPolicy = "cross-ns-policy" + } + + if c.ACLsEnabled { + // Must pre-create the cross-ns policy. + _, _, err = consulClient.ACL().PolicyCreate(&capi.ACLPolicy{ + Name: crossNSPolicy, + }, nil) + req.NoError(err) + } + + created, err := EnsureExists(consulClient, ns, crossNSPolicy) + req.NoError(err) + require.True(t, created) + + // Ensure it was created. + cNS, _, err := consulClient.Namespaces().Read(ns, nil) + req.NoError(err) + req.Equal("Auto-generated by consul-k8s", cNS.Description) + + if c.ACLsEnabled { + req.Len(cNS.ACLs.PolicyDefaults, 1) + req.Equal(cNS.ACLs.PolicyDefaults[0].Name, crossNSPolicy) + } else { + req.Len(cNS.ACLs.PolicyDefaults, 0) + } + }) + } +} + +func TestConsulNamespace(t *testing.T) { + cases := map[string]struct { + kubeNS string + enableConsulNamespaces bool + consulDestNS string + enableMirroring bool + mirroringPrefix string + expNS string + }{ + "namespaces disabled": { + enableConsulNamespaces: false, + expNS: "", + }, + "mirroring": { + enableConsulNamespaces: true, + enableMirroring: true, + kubeNS: "kube", + expNS: "kube", + }, + "mirroring with prefix": { + enableConsulNamespaces: true, + enableMirroring: true, + mirroringPrefix: "prefix-", + kubeNS: "kube", + expNS: "prefix-kube", + }, + "destination consul ns": { + enableConsulNamespaces: true, + consulDestNS: "dest", + kubeNS: "kube", + expNS: "dest", + }, + "mirroring takes precedence": { + enableConsulNamespaces: true, + consulDestNS: "dest", + enableMirroring: true, + kubeNS: "kube", + expNS: "kube", + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + act := ConsulNamespace(c.kubeNS, c.enableConsulNamespaces, c.consulDestNS, c.enableMirroring, c.mirroringPrefix) + require.Equal(t, c.expNS, act) + }) + } +} diff --git a/subcommand/controller/command.go b/subcommand/controller/command.go new file mode 100644 index 0000000000..e4744cba2b --- /dev/null +++ b/subcommand/controller/command.go @@ -0,0 +1,283 @@ +package controller + +import ( + "flag" + "fmt" + "sync" + + "github.com/hashicorp/consul-k8s/api/common" + "github.com/hashicorp/consul-k8s/api/v1alpha1" + "github.com/hashicorp/consul-k8s/controller" + "github.com/hashicorp/consul-k8s/subcommand/flags" + "github.com/mitchellh/cli" + "go.uber.org/zap/zapcore" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/klog/v2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/webhook" +) + +type Command struct { + UI cli.Ui + + flagSet *flag.FlagSet + k8s *flags.K8SFlags + httpFlags *flags.HTTPFlags + + flagWebhookTLSCertDir string + flagEnableLeaderElection bool + flagEnableWebhooks bool + flagDatacenter string + flagLogLevel string + + // Flags to support Consul Enterprise namespaces. + flagEnableNamespaces bool + flagConsulDestinationNamespace string + flagEnableNSMirroring bool + flagNSMirroringPrefix string + flagCrossNSACLPolicy string + + once sync.Once + help string +} + +var ( + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(v1alpha1.AddToScheme(scheme)) + // +kubebuilder:scaffold:scheme +} + +func (c *Command) init() { + c.flagSet = flag.NewFlagSet("", flag.ContinueOnError) + c.flagSet.BoolVar(&c.flagEnableLeaderElection, "enable-leader-election", false, + "Enable leader election for controller. "+ + "Enabling this will ensure there is only one active controller manager.") + c.flagSet.StringVar(&c.flagDatacenter, "datacenter", "", + "Name of the Consul datacenter the controller is operating in. This is added as metadata on managed custom resources.") + c.flagSet.BoolVar(&c.flagEnableNamespaces, "enable-namespaces", false, + "[Enterprise Only] Enables Consul Enterprise namespaces, in either a single Consul namespace or mirrored.") + c.flagSet.StringVar(&c.flagConsulDestinationNamespace, "consul-destination-namespace", "default", + "[Enterprise Only] Defines which Consul namespace to create all config entries in, regardless of their source Kubernetes namespace."+ + " If '-enable-k8s-namespace-mirroring' is true, this is not used.") + c.flagSet.BoolVar(&c.flagEnableNSMirroring, "enable-k8s-namespace-mirroring", false, "[Enterprise Only] Enables "+ + "k8s namespace mirroring.") + c.flagSet.StringVar(&c.flagNSMirroringPrefix, "k8s-namespace-mirroring-prefix", "", + "[Enterprise Only] Prefix that will be added to all k8s namespaces mirrored into Consul if mirroring is enabled.") + c.flagSet.StringVar(&c.flagCrossNSACLPolicy, "consul-cross-namespace-acl-policy", "", + "[Enterprise Only] Name of the ACL policy to attach to all created Consul namespaces to allow service "+ + "discovery across Consul namespaces. Only necessary if ACLs are enabled.") + c.flagSet.StringVar(&c.flagWebhookTLSCertDir, "webhook-tls-cert-dir", "", + "Directory that contains the TLS cert and key required for the webhook. The cert and key files must be named 'tls.crt' and 'tls.key' respectively.") + c.flagSet.BoolVar(&c.flagEnableWebhooks, "enable-webhooks", true, + "Enable webhooks. Disable when running locally since Kube API server won't be able to route to local server.") + c.flagSet.StringVar(&c.flagLogLevel, "log-level", zapcore.InfoLevel.String(), + fmt.Sprintf("Log verbosity level. Supported values (in order of detail) are "+ + "%q, %q, %q, and %q.", zapcore.DebugLevel.String(), zapcore.InfoLevel.String(), zapcore.WarnLevel.String(), zapcore.ErrorLevel.String())) + + c.httpFlags = &flags.HTTPFlags{} + flags.Merge(c.flagSet, c.httpFlags.Flags()) + c.help = flags.Usage(help, c.flagSet) +} + +func (c *Command) Run(args []string) int { + c.once.Do(c.init) + if err := c.flagSet.Parse(args); err != nil { + c.UI.Error(fmt.Sprintf("Parsing flagset: %s", err.Error())) + return 1 + } + if len(c.flagSet.Args()) > 0 { + c.UI.Error("Invalid arguments: should have no non-flag arguments") + return 1 + } + if c.flagEnableWebhooks && c.flagWebhookTLSCertDir == "" { + c.UI.Error("Invalid arguments: -webhook-tls-cert-dir must be set") + return 1 + } + if c.flagDatacenter == "" { + c.UI.Error("Invalid arguments: -datacenter must be set") + return 1 + } + + var zapLevel zapcore.Level + if err := zapLevel.UnmarshalText([]byte(c.flagLogLevel)); err != nil { + c.UI.Error(fmt.Sprintf("Error parsing -log-level %q: %s", c.flagLogLevel, err.Error())) + return 1 + } + // We set UseDevMode to true because we don't want our logs json + // formatted. + logger := zap.New(zap.UseDevMode(true), zap.Level(zapLevel)) + ctrl.SetLogger(logger) + klog.SetLogger(logger) + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Port: 9443, + LeaderElection: c.flagEnableLeaderElection, + LeaderElectionID: "consul.hashicorp.com", + Logger: logger, + }) + if err != nil { + setupLog.Error(err, "unable to start manager") + return 1 + } + + consulClient, err := c.httpFlags.APIClient() + if err != nil { + setupLog.Error(err, "connecting to Consul agent") + return 1 + } + + configEntryReconciler := &controller.ConfigEntryController{ + ConsulClient: consulClient, + DatacenterName: c.flagDatacenter, + EnableConsulNamespaces: c.flagEnableNamespaces, + ConsulDestinationNamespace: c.flagConsulDestinationNamespace, + EnableNSMirroring: c.flagEnableNSMirroring, + NSMirroringPrefix: c.flagNSMirroringPrefix, + CrossNSACLPolicy: c.flagCrossNSACLPolicy, + } + if err = (&controller.ServiceDefaultsController{ + ConfigEntryController: configEntryReconciler, + Client: mgr.GetClient(), + Log: ctrl.Log.WithName("controller").WithName(common.ServiceDefaults), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", common.ServiceDefaults) + return 1 + } + if err = (&controller.ServiceResolverController{ + ConfigEntryController: configEntryReconciler, + Client: mgr.GetClient(), + Log: ctrl.Log.WithName("controller").WithName(common.ServiceResolver), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", common.ServiceResolver) + return 1 + } + if err = (&controller.ProxyDefaultsController{ + ConfigEntryController: configEntryReconciler, + Client: mgr.GetClient(), + Log: ctrl.Log.WithName("controller").WithName(common.ProxyDefaults), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", common.ProxyDefaults) + return 1 + } + if err = (&controller.ServiceRouterController{ + ConfigEntryController: configEntryReconciler, + Client: mgr.GetClient(), + Log: ctrl.Log.WithName("controller").WithName(common.ServiceRouter), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", common.ServiceRouter) + return 1 + } + if err = (&controller.ServiceSplitterController{ + ConfigEntryController: configEntryReconciler, + Client: mgr.GetClient(), + Log: ctrl.Log.WithName("controller").WithName(common.ServiceSplitter), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", common.ServiceSplitter) + return 1 + } + if err = (&controller.ServiceIntentionsController{ + ConfigEntryController: configEntryReconciler, + Client: mgr.GetClient(), + Log: ctrl.Log.WithName("controller").WithName(common.ServiceIntentions), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", common.ServiceIntentions) + 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 + // automatically when new certificates are available. + mgr.GetWebhookServer().CertDir = c.flagWebhookTLSCertDir + + // Note: The path here should be identical to the one on the kubebuilder + // annotation in each webhook file. + mgr.GetWebhookServer().Register("/mutate-v1alpha1-servicedefaults", + &webhook.Admission{Handler: &v1alpha1.ServiceDefaultsWebhook{ + Client: mgr.GetClient(), + ConsulClient: consulClient, + Logger: ctrl.Log.WithName("webhooks").WithName(common.ServiceDefaults), + EnableConsulNamespaces: c.flagEnableNamespaces, + EnableNSMirroring: c.flagEnableNSMirroring, + }}) + mgr.GetWebhookServer().Register("/mutate-v1alpha1-serviceresolver", + &webhook.Admission{Handler: &v1alpha1.ServiceResolverWebhook{ + Client: mgr.GetClient(), + ConsulClient: consulClient, + Logger: ctrl.Log.WithName("webhooks").WithName(common.ServiceResolver), + EnableConsulNamespaces: c.flagEnableNamespaces, + EnableNSMirroring: c.flagEnableNSMirroring, + }}) + mgr.GetWebhookServer().Register("/mutate-v1alpha1-proxydefaults", + &webhook.Admission{Handler: &v1alpha1.ProxyDefaultsWebhook{ + Client: mgr.GetClient(), + ConsulClient: consulClient, + Logger: ctrl.Log.WithName("webhooks").WithName(common.ProxyDefaults), + EnableConsulNamespaces: c.flagEnableNamespaces, + EnableNSMirroring: c.flagEnableNSMirroring, + }}) + mgr.GetWebhookServer().Register("/mutate-v1alpha1-servicerouter", + &webhook.Admission{Handler: &v1alpha1.ServiceRouterWebhook{ + Client: mgr.GetClient(), + ConsulClient: consulClient, + Logger: ctrl.Log.WithName("webhooks").WithName(common.ServiceRouter), + EnableConsulNamespaces: c.flagEnableNamespaces, + EnableNSMirroring: c.flagEnableNSMirroring, + }}) + mgr.GetWebhookServer().Register("/mutate-v1alpha1-servicesplitter", + &webhook.Admission{Handler: &v1alpha1.ServiceSplitterWebhook{ + Client: mgr.GetClient(), + ConsulClient: consulClient, + Logger: ctrl.Log.WithName("webhooks").WithName(common.ServiceSplitter), + EnableConsulNamespaces: c.flagEnableNamespaces, + EnableNSMirroring: c.flagEnableNSMirroring, + }}) + mgr.GetWebhookServer().Register("/mutate-v1alpha1-serviceintentions", + &webhook.Admission{Handler: &v1alpha1.ServiceIntentionsWebhook{ + Client: mgr.GetClient(), + ConsulClient: consulClient, + Logger: ctrl.Log.WithName("webhooks").WithName(common.ServiceIntentions), + EnableConsulNamespaces: c.flagEnableNamespaces, + EnableNSMirroring: c.flagEnableNSMirroring, + }}) + } + // +kubebuilder:scaffold:builder + + setupLog.Info("starting manager") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + setupLog.Error(err, "problem running manager") + return 1 + } + return 0 +} + +func (c *Command) Help() string { + c.once.Do(c.init) + return c.help +} + +func (c *Command) Synopsis() string { + return synopsis +} + +const synopsis = "Starts the Consul Kubernetes controller" +const help = ` +Usage: consul-k8s controller [options] + + Starts the Consul Kubernetes controller that manages Consul Custom Resource Definitions + +` diff --git a/subcommand/controller/command_test.go b/subcommand/controller/command_test.go new file mode 100644 index 0000000000..50ba5b963a --- /dev/null +++ b/subcommand/controller/command_test.go @@ -0,0 +1,44 @@ +package controller + +import ( + "testing" + + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" +) + +func TestRun_FlagValidation(t *testing.T) { + t.Parallel() + + cases := []struct { + flags []string + expErr string + }{ + { + flags: nil, + expErr: "-webhook-tls-cert-dir must be set", + }, + { + flags: []string{"-datacenter", "foo"}, + expErr: "-webhook-tls-cert-dir must be set", + }, + { + flags: []string{"-webhook-tls-cert-dir", "/foo"}, + expErr: "-datacenter must be set", + }, + { + flags: []string{"-webhook-tls-cert-dir", "/foo", "-datacenter", "foo", "-log-level", "invalid"}, + expErr: `Error parsing -log-level "invalid": unrecognized level: "invalid"`, + }, + } + + for _, c := range cases { + t.Run(c.expErr, func(tt *testing.T) { + ui := cli.NewMockUi() + cmd := Command{UI: ui} + exitCode := cmd.Run(c.flags) + require.Equal(tt, 1, exitCode, ui.ErrorWriter.String()) + require.Contains(tt, ui.ErrorWriter.String(), c.expErr) + }) + } +} diff --git a/subcommand/delete-completed-job/command_test.go b/subcommand/delete-completed-job/command_test.go index a07054e06b..0da056fc88 100644 --- a/subcommand/delete-completed-job/command_test.go +++ b/subcommand/delete-completed-job/command_test.go @@ -34,7 +34,7 @@ func TestRun_ArgValidation(t *testing.T) { }, { []string{"-k8s-namespace=default", "-timeout=10jd", "job-name"}, - "\"10jd\" is not a valid timeout: time: unknown unit jd in duration 10jd", + "\"10jd\" is not a valid timeout", }, } for _, c := range cases { diff --git a/subcommand/flags/mapset.go b/subcommand/flags/mapset.go new file mode 100644 index 0000000000..c58cc9a3a2 --- /dev/null +++ b/subcommand/flags/mapset.go @@ -0,0 +1,12 @@ +package flags + +import "github.com/deckarep/golang-set" + +// ToSet creates a set from s. +func ToSet(s []string) mapset.Set { + set := mapset.NewSet() + for _, allow := range s { + set.Add(allow) + } + return set +} diff --git a/subcommand/inject-connect/command.go b/subcommand/inject-connect/command.go index 97c56cf8f1..3c23e765d1 100644 --- a/subcommand/inject-connect/command.go +++ b/subcommand/inject-connect/command.go @@ -13,7 +13,6 @@ import ( "sync/atomic" "time" - "github.com/deckarep/golang-set" "github.com/hashicorp/consul-k8s/connect-inject" "github.com/hashicorp/consul-k8s/helper/cert" "github.com/hashicorp/consul-k8s/subcommand/flags" @@ -114,12 +113,12 @@ func (c *Command) init() { c.flagSet.Var((*flags.AppendSliceValue)(&c.flagDenyK8sNamespacesList), "deny-k8s-namespace", "K8s namespaces to explicitly deny. Takes precedence over allow. May be specified multiple times.") c.flagSet.BoolVar(&c.flagEnableNamespaces, "enable-namespaces", false, - "[Enterprise Only] Enables namespaces, in either a single Consul namespace or mirrored") + "[Enterprise Only] Enables namespaces, in either a single Consul namespace or mirrored.") c.flagSet.StringVar(&c.flagConsulDestinationNamespace, "consul-destination-namespace", "default", - "[Enterprise Only] Defines which Consul namespace to register all injected services into. If '-enable-namespace-mirroring' "+ + "[Enterprise Only] Defines which Consul namespace to register all injected services into. If '-enable-k8s-namespace-mirroring' "+ "is true, this is not used.") c.flagSet.BoolVar(&c.flagEnableK8SNSMirroring, "enable-k8s-namespace-mirroring", false, "[Enterprise Only] Enables "+ - "k8s namespace mirroring") + "k8s namespace mirroring.") c.flagSet.StringVar(&c.flagK8SNSMirroringPrefix, "k8s-namespace-mirroring-prefix", "", "[Enterprise Only] Prefix that will be added to all k8s namespaces mirrored into Consul if mirroring is enabled.") c.flagSet.StringVar(&c.flagCrossNamespaceACLPolicy, "consul-cross-namespace-acl-policy", "", @@ -269,7 +268,7 @@ func (c *Command) Run(args []string) int { // Create the certificate notifier so we can update for certificates, // then start all the background routines for updating certificates. - certCh := make(chan cert.Bundle) + certCh := make(chan cert.MetaBundle) certNotify := &cert.Notify{Ch: certCh, Source: certSource} defer certNotify.Stop() go certNotify.Start(context.Background()) @@ -278,14 +277,8 @@ func (c *Command) Run(args []string) int { go c.certWatcher(ctx, certCh, c.clientset) // Convert allow/deny lists to sets - allowSet := mapset.NewSet() - denySet := mapset.NewSet() - for _, allow := range c.flagAllowK8sNamespacesList { - allowSet.Add(allow) - } - for _, deny := range c.flagDenyK8sNamespacesList { - denySet.Add(deny) - } + allowK8sNamespaces := flags.ToSet(c.flagAllowK8sNamespacesList) + denyK8sNamespaces := flags.ToSet(c.flagDenyK8sNamespacesList) // Build the HTTP handler and server injector := connectinject.Handler{ @@ -305,8 +298,8 @@ func (c *Command) Run(args []string) int { InitContainerResources: initResources, LifecycleSidecarResources: lifecycleResources, EnableNamespaces: c.flagEnableNamespaces, - AllowK8sNamespacesSet: allowSet, - DenyK8sNamespacesSet: denySet, + AllowK8sNamespacesSet: allowK8sNamespaces, + DenyK8sNamespacesSet: denyK8sNamespaces, ConsulDestinationNamespace: c.flagConsulDestinationNamespace, EnableK8SNSMirroring: c.flagEnableK8SNSMirroring, K8SNSMirroringPrefix: c.flagK8SNSMirroringPrefix, @@ -348,8 +341,8 @@ func (c *Command) getCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) return certRaw.(*tls.Certificate), nil } -func (c *Command) certWatcher(ctx context.Context, ch <-chan cert.Bundle, clientset kubernetes.Interface) { - var bundle cert.Bundle +func (c *Command) certWatcher(ctx context.Context, ch <-chan cert.MetaBundle, clientset kubernetes.Interface) { + var bundle cert.MetaBundle for { select { case bundle = <-ch: diff --git a/subcommand/inject-connect/command_test.go b/subcommand/inject-connect/command_test.go index 14fbb7ed6e..2445f1084d 100644 --- a/subcommand/inject-connect/command_test.go +++ b/subcommand/inject-connect/command_test.go @@ -10,7 +10,6 @@ import ( func TestRun_FlagValidation(t *testing.T) { cases := []struct { - name string flags []string expErr string }{ diff --git a/subcommand/server-acl-init/command.go b/subcommand/server-acl-init/command.go index 72ead467ce..82d22a000c 100644 --- a/subcommand/server-acl-init/command.go +++ b/subcommand/server-acl-init/command.go @@ -47,6 +47,8 @@ type Command struct { flagInjectAuthMethodHost string flagBindingRuleSelector string + flagCreateControllerToken bool + flagCreateEntLicenseToken bool flagCreateSnapshotAgentToken bool @@ -126,6 +128,9 @@ func (c *Command) init() { c.flags.StringVar(&c.flagBindingRuleSelector, "acl-binding-rule-selector", "", "Selector string for connectInject ACL Binding Rule.") + c.flags.BoolVar(&c.flagCreateControllerToken, "create-controller-token", false, + "Toggle for creating a token for the controller.") + c.flags.BoolVar(&c.flagCreateEntLicenseToken, "create-enterprise-license-token", false, "Toggle for creating a token for the enterprise license job.") c.flags.BoolVar(&c.flagCreateSnapshotAgentToken, "create-snapshot-agent-token", false, @@ -640,6 +645,19 @@ func (c *Command) Run(args []string) int { } } + if c.flagCreateControllerToken { + rules, err := c.controllerRules() + if err != nil { + c.log.Error("Error templating controller token rules", "err", err) + return 1 + } + err = c.createLocalACL("controller", rules, consulDC, consulClient) + if err != nil { + c.log.Error(err.Error()) + return 1 + } + } + c.log.Info("server-acl-init completed successfully") return 0 } diff --git a/subcommand/server-acl-init/command_ent_test.go b/subcommand/server-acl-init/command_ent_test.go index 65f3aebf1c..000648539e 100644 --- a/subcommand/server-acl-init/command_ent_test.go +++ b/subcommand/server-acl-init/command_ent_test.go @@ -105,7 +105,7 @@ func TestRun_ConnectInject_SingleDestinationNamespace(t *testing.T) { require.NoError(err) require.NotNil(actNamespace) require.Equal(consulDestNamespace, actNamespace.Name) - require.Equal("Auto-generated by the ACL bootstrapping process", actNamespace.Description) + require.Equal("Auto-generated by consul-k8s", actNamespace.Description) require.NotNil(actNamespace.ACLs) require.Len(actNamespace.ACLs.PolicyDefaults, 1) require.Equal("cross-namespace-policy", actNamespace.ACLs.PolicyDefaults[0].Name) diff --git a/subcommand/server-acl-init/command_test.go b/subcommand/server-acl-init/command_test.go index 84749192fc..c68b57fcbf 100644 --- a/subcommand/server-acl-init/command_test.go +++ b/subcommand/server-acl-init/command_test.go @@ -237,6 +237,14 @@ func TestRun_TokensPrimaryDC(t *testing.T) { SecretNames: []string{resourcePrefix + "-acl-replication-acl-token"}, LocalToken: false, }, + { + TestName: "Controller token", + TokenFlags: []string{"-create-controller-token"}, + PolicyNames: []string{"controller-token"}, + PolicyDCs: []string{"dc1"}, + SecretNames: []string{resourcePrefix + "-controller-acl-token"}, + LocalToken: true, + }, } for _, c := range cases { t.Run(c.TestName, func(t *testing.T) { diff --git a/subcommand/server-acl-init/connect_inject.go b/subcommand/server-acl-init/connect_inject.go index 3b7b3a193b..28c500f179 100644 --- a/subcommand/server-acl-init/connect_inject.go +++ b/subcommand/server-acl-init/connect_inject.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "github.com/hashicorp/consul-k8s/namespaces" "github.com/hashicorp/consul/api" apiv1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -46,7 +47,7 @@ func (c *Command) configureConnectInject(consulClient *api.Client) error { err = c.untilSucceeds(fmt.Sprintf("checking or creating namespace %s", c.flagConsulInjectDestinationNamespace), func() error { - err := c.checkAndCreateNamespace(c.flagConsulInjectDestinationNamespace, consulClient) + _, err := namespaces.EnsureExists(consulClient, c.flagConsulInjectDestinationNamespace, "cross-namespace-policy") return err }) if err != nil { diff --git a/subcommand/server-acl-init/create_or_update.go b/subcommand/server-acl-init/create_or_update.go index cf847fff9c..4607b6e12a 100644 --- a/subcommand/server-acl-init/create_or_update.go +++ b/subcommand/server-acl-init/create_or_update.go @@ -145,40 +145,6 @@ func (c *Command) createOrUpdateACLPolicy(policy api.ACLPolicy, consulClient *ap return err } -func (c *Command) checkAndCreateNamespace(ns string, consulClient *api.Client) error { - // Check if the Consul namespace exists - namespaceInfo, _, err := consulClient.Namespaces().Read(ns, nil) - if err != nil { - return err - } - - // If not, create it - if namespaceInfo == nil { - // Create the ACLs config for the cross-Consul-namespace - // default policy that needs to be attached - aclConfig := api.NamespaceACLConfig{ - PolicyDefaults: []api.ACLLink{ - {Name: "cross-namespace-policy"}, - }, - } - - consulNamespace := api.Namespace{ - Name: ns, - Description: "Auto-generated by the ACL bootstrapping process", - ACLs: &aclConfig, - Meta: map[string]string{"external-source": "kubernetes"}, - } - - _, _, err = consulClient.Namespaces().Create(&consulNamespace, nil) - if err != nil { - return err - } - c.log.Info("created consul namespace", "name", consulNamespace.Name) - } - - return nil -} - // isPolicyExistsErr returns true if err is due to trying to call the // policy create API when the policy already exists. func isPolicyExistsErr(err error, policyName string) bool { diff --git a/subcommand/server-acl-init/rules.go b/subcommand/server-acl-init/rules.go index cd0e4fcb9f..89501f2e0b 100644 --- a/subcommand/server-acl-init/rules.go +++ b/subcommand/server-acl-init/rules.go @@ -7,11 +7,14 @@ import ( ) type rulesData struct { - EnableNamespaces bool - ConsulSyncDestinationNamespace string - EnableSyncK8SNSMirroring bool - SyncK8SNSMirroringPrefix string - SyncConsulNodeName string + EnableNamespaces bool + SyncConsulDestNS string + SyncEnableNSMirroring bool + SyncNSMirroringPrefix string + InjectConsulDestNS string + InjectEnableNSMirroring bool + InjectNSMirroringPrefix string + SyncConsulNodeName string } type gatewayRulesData struct { @@ -180,10 +183,10 @@ func (c *Command) syncRules() (string, error) { } {{- if .EnableNamespaces }} operator = "write" -{{- if .EnableSyncK8SNSMirroring }} -namespace_prefix "{{ .SyncK8SNSMirroringPrefix }}" { +{{- if .SyncEnableNSMirroring }} +namespace_prefix "{{ .SyncNSMirroringPrefix }}" { {{- else }} -namespace "{{ .ConsulSyncDestinationNamespace }}" { +namespace "{{ .SyncConsulDestNS }}" { {{- end }} {{- end }} node_prefix "" { @@ -241,13 +244,37 @@ namespace_prefix "" { return c.renderRules(aclReplicationRulesTpl) } +func (c *Command) controllerRules() (string, error) { + controllerRules := ` +operator = "write" +{{- if .EnableNamespaces }} +{{- if .InjectEnableNSMirroring }} +namespace_prefix "{{ .InjectNSMirroringPrefix }}" { +{{- else }} +namespace "{{ .InjectConsulDestNS }}" { +{{- end }} +{{- end }} + service_prefix "" { + policy = "write" + intentions = "write" + } +{{- if .EnableNamespaces }} +} +{{- end }} +` + return c.renderRules(controllerRules) +} + func (c *Command) rulesData() rulesData { return rulesData{ - EnableNamespaces: c.flagEnableNamespaces, - ConsulSyncDestinationNamespace: c.flagConsulSyncDestinationNamespace, - EnableSyncK8SNSMirroring: c.flagEnableSyncK8SNSMirroring, - SyncK8SNSMirroringPrefix: c.flagSyncK8SNSMirroringPrefix, - SyncConsulNodeName: c.flagSyncConsulNodeName, + EnableNamespaces: c.flagEnableNamespaces, + SyncConsulDestNS: c.flagConsulSyncDestinationNamespace, + SyncEnableNSMirroring: c.flagEnableSyncK8SNSMirroring, + SyncNSMirroringPrefix: c.flagSyncK8SNSMirroringPrefix, + InjectConsulDestNS: c.flagConsulInjectDestinationNamespace, + InjectEnableNSMirroring: c.flagEnableInjectK8SNSMirroring, + InjectNSMirroringPrefix: c.flagInjectK8SNSMirroringPrefix, + SyncConsulNodeName: c.flagSyncConsulNodeName, } } diff --git a/subcommand/server-acl-init/rules_test.go b/subcommand/server-acl-init/rules_test.go index 2adaa3b689..4155f9c5df 100644 --- a/subcommand/server-acl-init/rules_test.go +++ b/subcommand/server-acl-init/rules_test.go @@ -582,3 +582,79 @@ namespace_prefix "" { }) } } + +func TestControllerRules(t *testing.T) { + cases := []struct { + Name string + EnableNamespaces bool + DestConsulNS string + Mirroring bool + MirroringPrefix string + Expected string + }{ + { + Name: "namespaces=disabled", + EnableNamespaces: false, + Expected: `operator = "write" + service_prefix "" { + policy = "write" + intentions = "write" + }`, + }, + { + Name: "namespaces=enabled, consulDestNS=consul", + EnableNamespaces: true, + DestConsulNS: "consul", + Expected: `operator = "write" +namespace "consul" { + service_prefix "" { + policy = "write" + intentions = "write" + } +}`, + }, + { + Name: "namespaces=enabled, mirroring=true", + EnableNamespaces: true, + Mirroring: true, + Expected: `operator = "write" +namespace_prefix "" { + service_prefix "" { + policy = "write" + intentions = "write" + } +}`, + }, + { + Name: "namespaces=enabled, mirroring=true, mirroringPrefix=prefix-", + EnableNamespaces: true, + Mirroring: true, + MirroringPrefix: "prefix-", + Expected: `operator = "write" +namespace_prefix "prefix-" { + service_prefix "" { + policy = "write" + intentions = "write" + } +}`, + }, + } + + for _, tt := range cases { + t.Run(tt.Name, func(t *testing.T) { + require := require.New(t) + + cmd := Command{ + flagEnableNamespaces: tt.EnableNamespaces, + flagConsulInjectDestinationNamespace: tt.DestConsulNS, + flagEnableInjectK8SNSMirroring: tt.Mirroring, + flagInjectK8SNSMirroringPrefix: tt.MirroringPrefix, + } + + rules, err := cmd.controllerRules() + + require.NoError(err) + require.Equal(tt.Expected, rules) + }) + } +} diff --git a/subcommand/sync-catalog/command.go b/subcommand/sync-catalog/command.go index a9911ace0a..5bf20a9fe1 100644 --- a/subcommand/sync-catalog/command.go +++ b/subcommand/sync-catalog/command.go @@ -126,12 +126,12 @@ func (c *Command) init() { c.flags.Var((*flags.AppendSliceValue)(&c.flagDenyK8sNamespacesList), "deny-k8s-namespace", "K8s namespaces to explicitly deny. Takes precedence over allow. May be specified multiple times.") c.flags.BoolVar(&c.flagEnableNamespaces, "enable-namespaces", false, - "[Enterprise Only] Enables namespaces, in either a single Consul namespace or mirrored") + "[Enterprise Only] Enables namespaces, in either a single Consul namespace or mirrored.") c.flags.StringVar(&c.flagConsulDestinationNamespace, "consul-destination-namespace", "default", - "[Enterprise Only] Defines which Consul namespace to register all synced services into. If 'enable-namespace-mirroring' "+ + "[Enterprise Only] Defines which Consul namespace to register all synced services into. If '-enable-k8s-namespace-mirroring' "+ "is true, this is not used.") c.flags.BoolVar(&c.flagEnableK8SNSMirroring, "enable-k8s-namespace-mirroring", false, "[Enterprise Only] Enables "+ - "namespace mirroring") + "namespace mirroring.") c.flags.StringVar(&c.flagK8SNSMirroringPrefix, "k8s-namespace-mirroring-prefix", "", "[Enterprise Only] Prefix that will be added to all k8s namespaces mirrored into Consul if mirroring is enabled.") c.flags.StringVar(&c.flagCrossNamespaceACLPolicy, "consul-cross-namespace-acl-policy", "", @@ -209,19 +209,12 @@ func (c *Command) Run(args []string) int { } // Convert allow/deny lists to sets - allowSet := mapset.NewSet() - denySet := mapset.NewSet() + allowSet := flags.ToSet(c.flagAllowK8sNamespacesList) + denySet := flags.ToSet(c.flagDenyK8sNamespacesList) if c.flagK8SSourceNamespace != "" { // For backwards compatibility, if `flagK8SSourceNamespace` is set, // it will be the only allowed namespace - allowSet.Add(c.flagK8SSourceNamespace) - } else { - for _, allow := range c.flagAllowK8sNamespacesList { - allowSet.Add(allow) - } - for _, deny := range c.flagDenyK8sNamespacesList { - denySet.Add(deny) - } + allowSet = mapset.NewSet(c.flagK8SSourceNamespace) } c.logger.Info("K8s namespace syncing configuration", "k8s namespaces allowed to be synced", allowSet, "k8s namespaces denied from syncing", denySet) diff --git a/subcommand/sync-catalog/command_ent_test.go b/subcommand/sync-catalog/command_ent_test.go index 694538b742..80af158ea4 100644 --- a/subcommand/sync-catalog/command_ent_test.go +++ b/subcommand/sync-catalog/command_ent_test.go @@ -116,7 +116,7 @@ func TestRun_ToConsulSingleDestinationNamespace(t *testing.T) { // Check created namespace properties if ns != "default" { - require.Equalf(r, "Auto-generated by a Catalog Sync Process", actNamespace.Description, + require.Equalf(r, "Auto-generated by consul-k8s", actNamespace.Description, "wrong namespace description for namespace %s", ns) require.Containsf(r, actNamespace.Meta, "external-source", "namespace %s does not contain external-source metadata key", ns) @@ -266,7 +266,7 @@ func TestRun_ToConsulMirroringNamespaces(t *testing.T) { // Check created namespace properties if ns != "default" { - require.Equalf(r, "Auto-generated by a Catalog Sync Process", actNamespace.Description, + require.Equalf(r, "Auto-generated by consul-k8s", actNamespace.Description, "wrong namespace description for namespace %s", ns) require.Containsf(r, actNamespace.Meta, "external-source", "namespace %s does not contain external-source metadata key", ns) @@ -712,7 +712,7 @@ func TestRun_ToConsulNamespacesACLs(t *testing.T) { // Check created namespace properties if ns != "default" { - require.Equalf(r, "Auto-generated by a Catalog Sync Process", actNamespace.Description, + require.Equalf(r, "Auto-generated by consul-k8s", actNamespace.Description, "wrong namespace description for namespace %s", ns) require.Containsf(r, actNamespace.Meta, "external-source", "namespace %s does not contain external-source metadata key", ns) diff --git a/subcommand/webhook-cert-manager/command.go b/subcommand/webhook-cert-manager/command.go new file mode 100644 index 0000000000..afceb42b15 --- /dev/null +++ b/subcommand/webhook-cert-manager/command.go @@ -0,0 +1,379 @@ +package webhookcertmanager + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "io/ioutil" + "os" + "os/signal" + "strings" + "sync" + "time" + + "github.com/hashicorp/consul-k8s/helper/cert" + "github.com/hashicorp/consul-k8s/subcommand" + "github.com/hashicorp/consul-k8s/subcommand/flags" + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/go-multierror" + "github.com/mitchellh/cli" + 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/types" + "k8s.io/client-go/kubernetes" +) + +const ( + defaultCertExpiry = 24 * time.Hour + defaultRetryDuration = 1 * time.Second +) + +type Command struct { + UI cli.Ui + + flagSet *flag.FlagSet + k8s *flags.K8SFlags + + flagConfigFile string + flagLogLevel string + + clientset kubernetes.Interface + + once sync.Once + help string + sigCh chan os.Signal + logger hclog.Logger + + certExpiry *time.Duration // override default cert expiry of 24 hours if set (only set in tests) + source cert.Source // override default cert source of cert.GenSource if set (only in tests) +} + +func (c *Command) init() { + c.flagSet = flag.NewFlagSet("", flag.ContinueOnError) + c.flagSet.StringVar(&c.flagConfigFile, "config-file", "", + "Path to a config file to read webhook configs from. This file must be in JSON format.") + c.flagSet.StringVar(&c.flagLogLevel, "log-level", "info", + "Log verbosity level. Supported values (in order of detail) are \"trace\", "+ + "\"debug\", \"info\", \"warn\", and \"error\".") + + c.k8s = &flags.K8SFlags{} + flags.Merge(c.flagSet, c.k8s.Flags()) + c.help = flags.Usage(help, c.flagSet) + + // Wait on an interrupt to exit. This channel must be initialized before + // Run() is called so that there are no race conditions where the channel + // is not defined. + if c.sigCh == nil { + c.sigCh = make(chan os.Signal, 1) + signal.Notify(c.sigCh, os.Interrupt) + } +} + +func (c *Command) Run(args []string) int { + c.once.Do(c.init) + if err := c.flagSet.Parse(args); err != nil { + c.UI.Error(fmt.Sprintf("Error parsing flagSet: %s", err)) + return 1 + } + if len(c.flagSet.Args()) > 0 { + c.UI.Error("Invalid arguments: should have no non-flag arguments") + return 1 + } + + if c.flagConfigFile == "" { + c.UI.Error(fmt.Sprintf("-config-file must be set")) + return 1 + } + + // Create the Kubernetes clientset + if c.clientset == nil { + config, err := subcommand.K8SConfig(c.k8s.KubeConfig()) + if err != nil { + c.UI.Error(fmt.Sprintf("Error retrieving Kubernetes auth: %s", err)) + return 1 + } + c.clientset, err = kubernetes.NewForConfig(config) + if err != nil { + c.UI.Error(fmt.Sprintf("Error initializing Kubernetes client: %s", err)) + return 1 + } + } + + if c.logger == nil { + level := hclog.LevelFromString(c.flagLogLevel) + if level == hclog.NoLevel { + c.UI.Error(fmt.Sprintf("Unknown log level: %s", c.flagLogLevel)) + return 1 + } + c.logger = hclog.New(&hclog.LoggerOptions{ + Level: level, + Output: os.Stderr, + }) + } + + configFile, err := ioutil.ReadFile(c.flagConfigFile) + if err != nil { + c.UI.Error(fmt.Sprintf("Error reading config file from %s: %s", c.flagConfigFile, err)) + return 1 + } + var configs []webhookConfig + err = json.Unmarshal(configFile, &configs) + if err != nil { + c.UI.Error(fmt.Sprintf("Error unmarshalling config file: %s", err.Error())) + return 1 + } + + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + + for i, config := range configs { + if err := config.validate(ctx, c.clientset); err != nil { + c.UI.Error(fmt.Sprintf("Error parsing config at index %d: %s", i, err)) + return 1 + } + } + + certCh := make(chan cert.MetaBundle) + + // Create the certificate notifier so we can update certificates, + // then start all the background routines for updating certificates. + var notifiers []*cert.Notify + var expiry time.Duration + if c.certExpiry != nil { + expiry = *c.certExpiry + } else { + expiry = defaultCertExpiry + } + var certSource cert.Source + for _, config := range configs { + if c.source != nil { + certSource = c.source + } else { + certSource = &cert.GenSource{ + Name: "Consul Webhook Certificates", + Hosts: config.TLSAutoHosts, + Expiry: expiry, + } + } + certNotify := &cert.Notify{Source: certSource, Ch: certCh, WebhookConfigName: config.Name, SecretName: config.SecretName, SecretNamespace: config.SecretNamespace} + notifiers = append(notifiers, certNotify) + go certNotify.Start(ctx) + } + + go c.certWatcher(ctx, certCh, c.clientset, c.logger) + + // We define a signal handler for OS interrupts, and when an SIGINT is received, + // we gracefully shut down, by first stopping our cert notifiers and then cancelling + // all the contexts that have been created by the process. + select { + case <-c.sigCh: + cancelFunc() + for _, notifier := range notifiers { + notifier.Stop() + } + return 0 + } +} + +// certWatcher listens for a new MetaBundle on the ch channel for all webhooks and updates +// MutatingWebhooksConfigs and Secrets when a new Bundle is available on the channel. +func (c *Command) certWatcher(ctx context.Context, ch <-chan cert.MetaBundle, clientset kubernetes.Interface, log hclog.Logger) { + var bundle cert.MetaBundle + for { + select { + case bundle = <-ch: + log.Info(fmt.Sprintf("Updated certificate bundle received for %s; Updating webhook certs.", bundle.WebhookConfigName)) + // Bundle is updated, set it up + + case <-time.After(defaultRetryDuration): + // This forces the mutating ctrlWebhook config to remain updated + // fairly quickly. Helm upgrades will rewrite the contents of the + // CA bundle which will not be in sync with the certs in the system. + // This fast reconcile ensures the system recovers fairly quickly in case + // the secret or MWC gets deleted or reset. + + case <-ctx.Done(): + // Quit + return + } + + if err := c.reconcileCertificates(ctx, clientset, bundle, log); err != nil { + log.Error("failed to reconcile certificates", "err", err) + } + } +} + +// reconcileCertificates ensures the secret in the MetaBundle has the latest certificate from the MetaBundle and the caBundles on the +// MutatingWebhookConfiguration have the latest CA certificate from the MetaBundle. It updates them if they are outdated and exits early +// if they are up-to date. +func (c *Command) reconcileCertificates(ctx context.Context, clientset kubernetes.Interface, bundle cert.MetaBundle, log hclog.Logger) error { + iterLog := log.With("mutatingwebhookconfig", bundle.WebhookConfigName, "secret", bundle.SecretName, "secretNS", bundle.SecretNamespace) + + certSecret, err := clientset.CoreV1().Secrets(bundle.SecretNamespace).Get(ctx, bundle.SecretName, metav1.GetOptions{}) + if err != nil && k8serrors.IsNotFound(err) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: bundle.SecretName, + }, + Data: map[string][]byte{ + corev1.TLSCertKey: bundle.Cert, + corev1.TLSPrivateKeyKey: bundle.Key, + }, + Type: corev1.SecretTypeTLS, + } + + iterLog.Info("Creating Kubernetes secret with certificate") + if _, err = clientset.CoreV1().Secrets(bundle.SecretNamespace).Create(ctx, secret, metav1.CreateOptions{}); err != nil { + iterLog.Error(fmt.Sprintf("Error writing secret to API server: %s", err)) + return err + } + + iterLog.Info("Updating webhook configuration") + if err = c.updateWebhookConfig(ctx, bundle, clientset); err != nil { + iterLog.Error("Error updating webhook configuration") + return err + } + return nil + } else if err != nil { + iterLog.Error("getting secret from Kubernetes", "err", err) + return err + } + + // Don't update secret if the certificate and key are unchanged. + if bytes.Equal(certSecret.Data[corev1.TLSCertKey], bundle.Cert) && bytes.Equal(certSecret.Data[corev1.TLSPrivateKeyKey], bundle.Key) && c.webhookUpdated(ctx, bundle, clientset) { + return nil + } + + certSecret.Data[corev1.TLSCertKey] = bundle.Cert + certSecret.Data[corev1.TLSPrivateKeyKey] = bundle.Key + + iterLog.Info("Updating secret with new certificate") + _, err = clientset.CoreV1().Secrets(bundle.SecretNamespace).Update(ctx, certSecret, metav1.UpdateOptions{}) + if err != nil { + iterLog.Error("Error updating secret with certificate", "err", err) + return err + } + + iterLog.Info("Updating webhook configuration with new CA") + if err = c.updateWebhookConfig(ctx, bundle, clientset); err != nil { + iterLog.Error("Error updating webhook configuration", "err", err) + return err + } + return nil +} + +// updateWebhookConfig iterates over every webhook on the specified webhook configuration and updates +// their caBundle with the CA from the MetaBundle. +func (c *Command) updateWebhookConfig(ctx context.Context, metaBundle cert.MetaBundle, clientset kubernetes.Interface) error { + if len(metaBundle.CACert) == 0 { + return errors.New("no CA certificate in the bundle") + } + value := base64.StdEncoding.EncodeToString(metaBundle.CACert) + + webhookCfg, err := clientset.AdmissionregistrationV1beta1().MutatingWebhookConfigurations().Get(ctx, metaBundle.WebhookConfigName, metav1.GetOptions{}) + if err != nil { + return err + } + var patches []patch + for i := range webhookCfg.Webhooks { + patches = append(patches, patch{ + Op: "add", + Path: fmt.Sprintf("/webhooks/%d/clientConfig/caBundle", i), + Value: value, + }) + } + patchesJson, err := json.Marshal(patches) + if err != nil { + return err + } + + if _, err = clientset.AdmissionregistrationV1beta1().MutatingWebhookConfigurations().Patch(ctx, metaBundle.WebhookConfigName, types.JSONPatchType, patchesJson, metav1.PatchOptions{}); err != nil { + return err + } + return nil +} + +// webhookUpdated verifies if every caBundle on the specified webhook configuration matches the desired CA certificate. +// It returns true if the CA is up-to date and false if it needs to be updated. +func (c *Command) webhookUpdated(ctx context.Context, bundle cert.MetaBundle, clientset kubernetes.Interface) bool { + webhookCfg, err := clientset.AdmissionregistrationV1beta1().MutatingWebhookConfigurations().Get(ctx, bundle.WebhookConfigName, metav1.GetOptions{}) + if err != nil { + return false + } + for _, webhook := range webhookCfg.Webhooks { + if !bytes.Equal(webhook.ClientConfig.CABundle, bundle.CACert) { + return false + } + } + return true +} + +type webhookConfig struct { + Name string `json:"name,omitempty"` + TLSAutoHosts []string `json:"tlsAutoHosts,omitempty"` + SecretName string `json:"secretName,omitempty"` + SecretNamespace string `json:"secretNamespace,omitempty"` +} + +func (c webhookConfig) validate(ctx context.Context, client kubernetes.Interface) error { + var err *multierror.Error + if c.Name == "" { + err = multierror.Append(err, errors.New(`config.Name cannot be ""`)) + } else { + if _, err2 := client.AdmissionregistrationV1beta1().MutatingWebhookConfigurations().Get(ctx, c.Name, metav1.GetOptions{}); err2 != nil && k8serrors.IsNotFound(err2) { + err = multierror.Append(err, errors.New(fmt.Sprintf("MutatingWebhookConfiguration with name \"%s\" must exist in cluster", c.Name))) + } + } + if c.SecretName == "" { + err = multierror.Append(err, errors.New(`config.SecretName cannot be ""`)) + } + if c.SecretNamespace == "" { + err = multierror.Append(err, errors.New(`config.SecretNameSpace cannot be ""`)) + } + + if err != nil { + err.ErrorFormat = func(errs []error) string { + var errStr []string + for _, e := range errs { + errStr = append(errStr, e.Error()) + } + return strings.Join(errStr, ", ") + } + return err + } + return nil +} + +type patch struct { + Op string `json:"op,omitempty"` + Path string `json:"path,omitempty"` + Value string `json:"value,omitempty"` +} + +func (c *Command) Help() string { + c.once.Do(c.init) + return c.help +} + +func (c *Command) Synopsis() string { + return synopsis +} + +// interrupt sends os.Interrupt signal to the command +// so it can exit gracefully. This function is needed for tests +func (c *Command) interrupt() { + c.sigCh <- os.Interrupt +} + +const synopsis = "Starts the Consul Kubernetes webhook-cert-manager" +const help = ` +Usage: consul-k8s webhook-cert-manager [options] + + Starts the Consul Kubernetes webhook-cert-manager that manages the lifecycle for webhook TLS certificates. + +` diff --git a/subcommand/webhook-cert-manager/command_test.go b/subcommand/webhook-cert-manager/command_test.go new file mode 100644 index 0000000000..bb26fc71b5 --- /dev/null +++ b/subcommand/webhook-cert-manager/command_test.go @@ -0,0 +1,534 @@ +package webhookcertmanager + +import ( + "context" + "io/ioutil" + "os" + "testing" + "time" + + "github.com/hashicorp/consul-k8s/subcommand/webhook-cert-manager/mocks" + "github.com/hashicorp/consul/sdk/testutil/retry" + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" + admissionv1beta1 "k8s.io/api/admissionregistration/v1beta1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" +) + +func TestRun_FlagValidation(t *testing.T) { + t.Parallel() + + cases := []struct { + flags []string + expErr string + }{ + { + flags: nil, + expErr: "-config-file must be set", + }, + } + + for _, c := range cases { + t.Run(c.expErr, func(tt *testing.T) { + ui := cli.NewMockUi() + cmd := Command{ + UI: ui, + } + exitCode := cmd.Run(c.flags) + require.Equal(tt, 1, exitCode, ui.ErrorWriter.String()) + require.Contains(tt, ui.ErrorWriter.String(), c.expErr) + }) + } +} + +func TestRun_SecretDoesNotExist(t *testing.T) { + t.Parallel() + secretOneName := "secret-deploy-1" + secretTwoName := "secret-deploy-2" + + webhookConfigOneName := "webhookOne" + webhookConfigTwoName := "webhookTwo" + + caBundleOne := []byte("bootstrapped-CA-one") + caBundleTwo := []byte("bootstrapped-CA-two") + + webhookOne := &admissionv1beta1.MutatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: webhookConfigOneName, + }, + Webhooks: []admissionv1beta1.MutatingWebhook{ + { + Name: "webhook-under-test", + ClientConfig: admissionv1beta1.WebhookClientConfig{ + CABundle: caBundleOne, + }, + }, + }, + } + webhookTwo := &admissionv1beta1.MutatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: webhookConfigTwoName, + }, + Webhooks: []admissionv1beta1.MutatingWebhook{ + { + Name: "webhookOne-under-test", + ClientConfig: admissionv1beta1.WebhookClientConfig{ + CABundle: caBundleTwo, + }, + }, + { + Name: "webhookTwo-under-test", + ClientConfig: admissionv1beta1.WebhookClientConfig{ + CABundle: caBundleTwo, + }, + }, + }, + } + + k8s := fake.NewSimpleClientset(webhookOne, webhookTwo) + ui := cli.NewMockUi() + cmd := Command{ + UI: ui, + clientset: k8s, + } + cmd.init() + + file, err := ioutil.TempFile("", "config.json") + require.NoError(t, err) + defer os.Remove(file.Name()) + + _, err = file.Write([]byte(configFile)) + require.NoError(t, err) + + exitCh := runCommandAsynchronously(&cmd, []string{ + "-config-file", file.Name(), + }) + defer stopCommand(t, &cmd, exitCh) + + ctx := context.Background() + timer := &retry.Timer{Timeout: 10 * time.Second, Wait: 500 * time.Millisecond} + retry.RunWith(timer, t, func(r *retry.R) { + secretOne, err := k8s.CoreV1().Secrets("default").Get(ctx, secretOneName, metav1.GetOptions{}) + require.NoError(r, err) + require.Equal(r, secretOne.Type, v1.SecretTypeTLS) + + secretTwo, err := k8s.CoreV1().Secrets("default").Get(ctx, secretTwoName, metav1.GetOptions{}) + require.NoError(r, err) + require.Equal(r, secretTwo.Type, v1.SecretTypeTLS) + + webhookConfigOne, err := k8s.AdmissionregistrationV1beta1().MutatingWebhookConfigurations().Get(ctx, webhookConfigOneName, metav1.GetOptions{}) + require.NoError(r, err) + require.NotEqual(r, webhookConfigOne.Webhooks[0].ClientConfig.CABundle, caBundleOne) + + webhookConfigTwo, err := k8s.AdmissionregistrationV1beta1().MutatingWebhookConfigurations().Get(ctx, webhookConfigTwoName, metav1.GetOptions{}) + require.NoError(r, err) + require.NotEqual(r, webhookConfigTwo.Webhooks[0].ClientConfig.CABundle, caBundleTwo) + require.NotEqual(r, webhookConfigTwo.Webhooks[1].ClientConfig.CABundle, caBundleTwo) + require.Equal(r, webhookConfigTwo.Webhooks[0].ClientConfig.CABundle, webhookConfigTwo.Webhooks[1].ClientConfig.CABundle) + }) +} + +func TestRun_SecretExists(t *testing.T) { + t.Parallel() + secretOneName := "secret-deploy-1" + secretTwoName := "secret-deploy-2" + + webhookConfigOneName := "webhookOne" + webhookConfigTwoName := "webhookTwo" + + caBundleOne := []byte("bootstrapped-CA-one") + caBundleTwo := []byte("bootstrapped-CA-two") + + secretOne := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretOneName, + }, + StringData: map[string]string{ + v1.TLSCertKey: "cert-1", + v1.TLSPrivateKeyKey: "private-key-1", + }, + Type: v1.SecretTypeTLS, + } + secretTwo := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretTwoName, + }, + StringData: map[string]string{ + v1.TLSCertKey: "cert-2", + v1.TLSPrivateKeyKey: "private-key-2", + }, + Type: v1.SecretTypeTLS, + } + + webhookOne := &admissionv1beta1.MutatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: webhookConfigOneName, + }, + Webhooks: []admissionv1beta1.MutatingWebhook{ + { + Name: "webhook-under-test", + ClientConfig: admissionv1beta1.WebhookClientConfig{ + CABundle: caBundleOne, + }, + }, + }, + } + webhookTwo := &admissionv1beta1.MutatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: webhookConfigTwoName, + }, + Webhooks: []admissionv1beta1.MutatingWebhook{ + { + Name: "webhookOne-under-test", + ClientConfig: admissionv1beta1.WebhookClientConfig{ + CABundle: caBundleTwo, + }, + }, + { + Name: "webhookTwo-under-test", + ClientConfig: admissionv1beta1.WebhookClientConfig{ + CABundle: caBundleTwo, + }, + }, + }, + } + + k8s := fake.NewSimpleClientset(webhookOne, webhookTwo, secretOne, secretTwo) + ui := cli.NewMockUi() + cmd := Command{ + UI: ui, + clientset: k8s, + } + cmd.init() + + file, err := ioutil.TempFile("", "config.json") + require.NoError(t, err) + defer os.Remove(file.Name()) + + _, err = file.Write([]byte(configFile)) + require.NoError(t, err) + + exitCh := runCommandAsynchronously(&cmd, []string{ + "-config-file", file.Name(), + }) + defer stopCommand(t, &cmd, exitCh) + + ctx := context.Background() + timer := &retry.Timer{Timeout: 10 * time.Second, Wait: 500 * time.Millisecond} + retry.RunWith(timer, t, func(r *retry.R) { + secretOne, err := k8s.CoreV1().Secrets("default").Get(ctx, secretOneName, metav1.GetOptions{}) + require.NoError(r, err) + require.NotEqual(r, secretOne.Data[v1.TLSCertKey], []byte("cert-1")) + require.NotEqual(r, secretOne.Data[v1.TLSPrivateKeyKey], []byte("private-key-1")) + + secretTwo, err := k8s.CoreV1().Secrets("default").Get(ctx, secretTwoName, metav1.GetOptions{}) + require.NoError(r, err) + require.NotEqual(r, secretTwo.Data[v1.TLSCertKey], []byte("cert-2")) + require.NotEqual(r, secretTwo.Data[v1.TLSPrivateKeyKey], []byte("private-key-2")) + + webhookConfigOne, err := k8s.AdmissionregistrationV1beta1().MutatingWebhookConfigurations().Get(ctx, webhookConfigOneName, metav1.GetOptions{}) + require.NoError(r, err) + require.NotEqual(r, webhookConfigOne.Webhooks[0].ClientConfig.CABundle, caBundleOne) + + webhookConfigTwo, err := k8s.AdmissionregistrationV1beta1().MutatingWebhookConfigurations().Get(ctx, webhookConfigTwoName, metav1.GetOptions{}) + require.NoError(r, err) + require.NotEqual(r, webhookConfigTwo.Webhooks[0].ClientConfig.CABundle, caBundleTwo) + require.NotEqual(r, webhookConfigTwo.Webhooks[1].ClientConfig.CABundle, caBundleTwo) + require.Equal(r, webhookConfigTwo.Webhooks[0].ClientConfig.CABundle, webhookConfigTwo.Webhooks[1].ClientConfig.CABundle) + }) +} + +func TestRun_SecretUpdates(t *testing.T) { + t.Parallel() + secretOne := "secret-deploy-1" + + webhookConfigOne := "webhookOne" + + caBundleOne := []byte("bootstrapped-CA-one") + + secret1 := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretOne, + }, + StringData: map[string]string{ + v1.TLSCertKey: "cert-1", + v1.TLSPrivateKeyKey: "private-key-1", + }, + Type: v1.SecretTypeTLS, + } + + webhookOne := &admissionv1beta1.MutatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: webhookConfigOne, + }, + Webhooks: []admissionv1beta1.MutatingWebhook{ + { + Name: "webhook-under-test", + ClientConfig: admissionv1beta1.WebhookClientConfig{ + CABundle: caBundleOne, + }, + }, + }, + } + + k8s := fake.NewSimpleClientset(webhookOne, secret1) + ui := cli.NewMockUi() + oneSec := 1 * time.Second + + cmd := Command{ + UI: ui, + clientset: k8s, + certExpiry: &oneSec, + } + cmd.init() + + file, err := ioutil.TempFile("", "config.json") + require.NoError(t, err) + defer os.Remove(file.Name()) + + _, err = file.Write([]byte(configFileUpdates)) + require.NoError(t, err) + + exitCh := runCommandAsynchronously(&cmd, []string{ + "-config-file", file.Name(), + }) + defer stopCommand(t, &cmd, exitCh) + + var certificate, key []byte + + ctx := context.Background() + timer := &retry.Timer{Timeout: 10 * time.Second, Wait: 500 * time.Millisecond} + // First, check that the original secret contents are updated when the cert-manager starts. + retry.RunWith(timer, t, func(r *retry.R) { + secret1, err := k8s.CoreV1().Secrets("default").Get(ctx, secretOne, metav1.GetOptions{}) + require.NoError(r, err) + require.NotEqual(r, secret1.Data[v1.TLSCertKey], []byte("cert-1")) + require.NotEqual(r, secret1.Data[v1.TLSPrivateKeyKey], []byte("private-key-1")) + + certificate = secret1.Data[v1.TLSCertKey] + key = secret1.Data[v1.TLSPrivateKeyKey] + + webhookConfig1, err := k8s.AdmissionregistrationV1beta1().MutatingWebhookConfigurations().Get(ctx, webhookConfigOne, metav1.GetOptions{}) + require.NoError(r, err) + require.NotEqual(r, webhookConfig1.Webhooks[0].ClientConfig.CABundle, caBundleOne) + }) + + // Wait for certs to be rotated. + time.Sleep(2 * time.Second) + + timer = &retry.Timer{Timeout: 10 * time.Second, Wait: 500 * time.Millisecond} + // Check that the certificate is rotated and the secret is updated. + retry.RunWith(timer, t, func(r *retry.R) { + secret1, err := k8s.CoreV1().Secrets("default").Get(ctx, secretOne, metav1.GetOptions{}) + require.NoError(r, err) + require.NotEqual(r, secret1.Data[v1.TLSCertKey], certificate) + require.NotEqual(r, secret1.Data[v1.TLSPrivateKeyKey], key) + }) +} + +// This test verifies that when there is an error while attempting to update +// the certs or the webhook config, it retries the update every second until +// it succeeds. +func TestCertWatcher(t *testing.T) { + t.Parallel() + + webhookName := "webhookOne" + webhook := &admissionv1beta1.MutatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: webhookName, + }, + Webhooks: []admissionv1beta1.MutatingWebhook{ + { + Name: "webhook-under-test", + ClientConfig: admissionv1beta1.WebhookClientConfig{}, + }, + }, + } + certSource := &mocks.MockCertSource{} + + k8s := fake.NewSimpleClientset(webhook) + ui := cli.NewMockUi() + + cmd := Command{ + UI: ui, + clientset: k8s, + source: certSource, + } + cmd.init() + + file, err := ioutil.TempFile("", "config.json") + require.NoError(t, err) + defer os.Remove(file.Name()) + + _, err = file.Write([]byte(configFileUpdates)) + require.NoError(t, err) + + exitCh := runCommandAsynchronously(&cmd, []string{ + "-config-file", file.Name(), + }) + defer stopCommand(t, &cmd, exitCh) + + ctx := context.Background() + timer := &retry.Timer{Timeout: 5 * time.Second, Wait: 500 * time.Millisecond} + retry.RunWith(timer, t, func(r *retry.R) { + webhookConfig, err := k8s.AdmissionregistrationV1beta1().MutatingWebhookConfigurations().Get(ctx, webhookName, metav1.GetOptions{}) + require.NoError(r, err) + // Verify that the CA cert has been initally set on the MWC. + require.Contains(r, string(webhookConfig.Webhooks[0].ClientConfig.CABundle), "ca-certificate-string") + }) + // Update the CA bundle on the MWC to `""` to replicate a helm upgrade + webhook.Webhooks[0].ClientConfig.CABundle = []byte("") + _, err = k8s.AdmissionregistrationV1beta1().MutatingWebhookConfigurations().Update(ctx, webhook, metav1.UpdateOptions{}) + require.NoError(t, err) + + // If this test passes, it implies that the system has recovered from the MWC + // getting updated to have the correct CA within a reasonable time window + timer = &retry.Timer{Timeout: 5 * time.Second, Wait: 500 * time.Millisecond} + retry.RunWith(timer, t, func(r *retry.R) { + webhookConfig, err := k8s.AdmissionregistrationV1beta1().MutatingWebhookConfigurations().Get(ctx, webhookName, metav1.GetOptions{}) + require.NoError(r, err) + // Verify that the CA cert has been updated with the correct CA. + require.Contains(r, string(webhookConfig.Webhooks[0].ClientConfig.CABundle), "ca-certificate-string") + }) +} + +func TestValidate(t *testing.T) { + t.Parallel() + webhook := &admissionv1beta1.MutatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "webhook-config-name", + }, + } + client := fake.NewSimpleClientset(webhook) + + cases := map[string]struct { + config webhookConfig + clientset kubernetes.Interface + expErr string + }{ + "name": { + config: webhookConfig{ + Name: "", + TLSAutoHosts: []string{"host-1", "host-2"}, + SecretName: "secret-name", + SecretNamespace: "default", + }, + clientset: client, + expErr: `config.Name cannot be ""`, + }, + "nonExistantMWC": { + config: webhookConfig{ + Name: "webhook-config-name", + TLSAutoHosts: []string{"host-1", "host-2"}, + SecretName: "secret-name", + SecretNamespace: "default", + }, + clientset: fake.NewSimpleClientset(), + expErr: `MutatingWebhookConfiguration with name "webhook-config-name" must exist in cluster`, + }, + "secretName": { + config: webhookConfig{ + Name: "webhook-config-name", + TLSAutoHosts: []string{"host-1", "host-2"}, + SecretName: "", + SecretNamespace: "default", + }, + clientset: client, + expErr: `config.SecretName cannot be ""`, + }, + "secretNameSpace": { + config: webhookConfig{ + Name: "webhook-config-name", + TLSAutoHosts: []string{"host-1", "host-2"}, + SecretName: "secret-name", + SecretNamespace: "", + }, + clientset: client, + expErr: `config.SecretNameSpace cannot be ""`, + }, + "multi-error": { + config: webhookConfig{ + Name: "", + TLSAutoHosts: []string{}, + SecretName: "", + SecretNamespace: "", + }, + expErr: `config.Name cannot be "", config.SecretName cannot be "", config.SecretNameSpace cannot be ""`, + }, + } + + for name, c := range cases { + t.Run(name, func(tt *testing.T) { + err := c.config.validate(context.Background(), c.clientset) + require.EqualError(tt, err, c.expErr) + }) + } +} + +// This function starts the command asynchronously and returns a non-blocking chan. +// When finished, the command will send its exit code to the channel. +// Note that it's the responsibility of the caller to terminate the command by calling stopCommand, +// otherwise it can run forever. +func runCommandAsynchronously(cmd *Command, args []string) chan int { + // We have to run cmd.init() to ensure that the channel the command is + // using to watch for os interrupts is initialized. If we don't do this, + // then if stopCommand is called immediately, it will block forever + // because it calls interrupt() which will attempt to send on a nil channel. + cmd.init() + exitChan := make(chan int, 1) + + go func() { + exitChan <- cmd.Run(args) + }() + + return exitChan +} + +func stopCommand(t *testing.T, cmd *Command, exitChan chan int) { + if len(exitChan) == 0 { + cmd.interrupt() + } + select { + case c := <-exitChan: + require.Equal(t, 0, c, string(cmd.UI.(*cli.MockUi).ErrorWriter.Bytes())) + } +} + +const configFile = `[ + { + "name": "webhookOne", + "tlsAutoHosts": [ + "foo", + "bar", + "baz" + ], + "secretName": "secret-deploy-1", + "secretNamespace": "default" + }, + { + "name": "webhookTwo", + "tlsAutoHosts": [ + "foo-2", + "bar-3", + "baz-4" + ], + "secretName": "secret-deploy-2", + "secretNamespace": "default" + } +]` + +const configFileUpdates = `[ + { + "name": "webhookOne", + "tlsAutoHosts": [ + "foo", + "bar", + "baz" + ], + "secretName": "secret-deploy-1", + "secretNamespace": "default" + } +]` diff --git a/subcommand/webhook-cert-manager/mocks/mocks.go b/subcommand/webhook-cert-manager/mocks/mocks.go new file mode 100644 index 0000000000..40f54ff7cf --- /dev/null +++ b/subcommand/webhook-cert-manager/mocks/mocks.go @@ -0,0 +1,23 @@ +package mocks + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/consul-k8s/helper/cert" + "github.com/stretchr/testify/mock" +) + +type MockCertSource struct { + mock.Mock +} + +func (m *MockCertSource) Certificate(_ context.Context, _ *cert.Bundle) (cert.Bundle, error) { + result := cert.Bundle{ + Cert: []byte(fmt.Sprintf("certificate-string-%d", time.Now().Unix())), + Key: []byte(fmt.Sprintf("private-key-string-%d", time.Now().Unix())), + CACert: []byte(fmt.Sprintf("ca-certificate-string-%d", time.Now().Unix())), + } + return result, nil +}