Skip to content

Commit

Permalink
ServiceResolver controller and webhook
Browse files Browse the repository at this point in the history
* Also make controller and webhook code generic
* Update to controller-runtime 0.6.3 to fix spurious log error message.
  • Loading branch information
lkysow committed Sep 22, 2020
1 parent 767a5cf commit d62b005
Show file tree
Hide file tree
Showing 34 changed files with 2,743 additions and 761 deletions.
2 changes: 2 additions & 0 deletions api/common/common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package common holds code that isn't tied to a particular CRD version or type.
package common
50 changes: 50 additions & 0 deletions api/common/configentry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package common

import (
"github.com/hashicorp/consul/api"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)

// ConfigEntryResource is a generic config entry custom resource. It is implemented
// by each config entry type so that they can be acted upon generically.
// It is not tied to a specific CRD version.
type ConfigEntryResource interface {
// GetObjectMeta returns object meta.
GetObjectMeta() metav1.ObjectMeta
// AddFinalizer adds a finalizer to the list of finalizers.
AddFinalizer(name string)
// RemoveFinalizer removes this finalizer from the list.
RemoveFinalizer(name string)
// Finalizers returns the list of finalizers for this object.
Finalizers() []string
// ConsulKind returns the Consul config entry kind, i.e. service-defaults, not
// servicedefaults.
ConsulKind() string
// KubeKind returns the Kube config entry kind, i.e. servicedefaults, not
// service-defaults.
KubeKind() string
// Name returns the name of the config entry.
Name() string
// SetSyncedCondition updates the synced condition.
SetSyncedCondition(status corev1.ConditionStatus, reason, message string)
// SyncedCondition gets the synced condition.
SyncedCondition() (status corev1.ConditionStatus, reason, message string)
// SyncedConditionStatus returns the status of the synced condition.
SyncedConditionStatus() corev1.ConditionStatus
// ToConsul converts the resource to the corresponding Consul API definition.
// Its return type is the generic ConfigEntry but a specific config entry
// type should be constructed e.g. ServiceConfigEntry.
ToConsul() api.ConfigEntry
// MatchesConsul returns true if the resource has the same fields as the Consul
// config entry.
MatchesConsul(candidate api.ConfigEntry) bool
// GetObjectKind should be implemented by the generated code.
GetObjectKind() schema.ObjectKind
// DeepCopyObject should be implemented by the generated code.
DeepCopyObject() runtime.Object
// Validate returns an error if the resource is invalid.
Validate() error
}
56 changes: 56 additions & 0 deletions api/common/configentry_webhook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package common

import (
"context"
"fmt"
"net/http"

"github.com/go-logr/logr"
"k8s.io/api/admission/v1beta1"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)

// ConfigEntryLister is implemented by CRD-specific webhooks.
type ConfigEntryLister interface {
// List returns all resources of this type across all namespaces in a
// Kubernetes cluster.
List(ctx context.Context) ([]ConfigEntryResource, error)
}

// ValidateConfigEntry validates cfgEntry. It is a generic method that
// can be used by all CRD-specific validators.
// Callers should pass themselves as validator and kind should be the custom
// resource name, e.g. "ServiceDefaults".
func ValidateConfigEntry(
ctx context.Context,
req admission.Request,
logger logr.Logger,
configEntryLister ConfigEntryLister,
cfgEntry ConfigEntryResource) admission.Response {

// On create we need to validate that there isn't already a resource with
// the same name in a different namespace since we need to map all Kube
// resources to a single Consul namespace.
if req.Operation == v1beta1.Create {
logger.Info("validate create", "name", cfgEntry.Name())

list, err := configEntryLister.List(ctx)
if err != nil {
return admission.Errored(http.StatusInternalServerError, err)
}
for _, item := range list {
if item.Name() == cfgEntry.Name() {
// todo: If running Consul Ent with mirroring need to change this to respect namespaces.
return admission.Errored(http.StatusBadRequest,
fmt.Errorf("%s resource with name %q is already defined – all %s resources must have unique names across namespaces",
cfgEntry.KubeKind(),
cfgEntry.Name(),
cfgEntry.KubeKind()))
}
}
}
if err := cfgEntry.Validate(); err != nil {
return admission.Errored(http.StatusBadRequest, err)
}
return admission.Allowed(fmt.Sprintf("valid %s request", cfgEntry.KubeKind()))
}
161 changes: 161 additions & 0 deletions api/common/configentry_webhook_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package common

import (
"context"
"encoding/json"
"errors"
"testing"

logrtest "github.com/go-logr/logr/testing"
capi "github.com/hashicorp/consul/api"
"github.com/stretchr/testify/require"
"k8s.io/api/admission/v1beta1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)

func TestValidateConfigEntry(t *testing.T) {
otherNS := "other"

cases := map[string]struct {
existingResources []ConfigEntryResource
newResource ConfigEntryResource
expAllow bool
expErrMessage string
}{
"no duplicates, valid": {
existingResources: nil,
newResource: &mockConfigEntry{
MockName: "foo",
MockNamespace: otherNS,
Valid: true,
},
expAllow: true,
},
"no duplicates, invalid": {
existingResources: nil,
newResource: &mockConfigEntry{
MockName: "foo",
MockNamespace: otherNS,
Valid: false,
},
expAllow: false,
expErrMessage: "invalid",
},
"duplicate name": {
existingResources: []ConfigEntryResource{&mockConfigEntry{
MockName: "foo",
MockNamespace: "default",
}},
newResource: &mockConfigEntry{
MockName: "foo",
MockNamespace: otherNS,
Valid: true,
},
expAllow: false,
expErrMessage: "mockkind resource with name \"foo\" is already defined – all mockkind resources must have unique names across namespaces",
},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
ctx := context.Background()
marshalledRequestObject, err := json.Marshal(c.newResource)
require.NoError(t, err)

lister := &mockConfigEntryLister{
Resources: c.existingResources,
}
response := ValidateConfigEntry(ctx, admission.Request{
AdmissionRequest: v1beta1.AdmissionRequest{
Name: c.newResource.Name(),
Namespace: otherNS,
Operation: v1beta1.Create,
Object: runtime.RawExtension{
Raw: marshalledRequestObject,
},
},
},
logrtest.TestLogger{T: t},
lister,
c.newResource)
require.Equal(t, c.expAllow, response.Allowed)
if c.expErrMessage != "" {
require.Equal(t, c.expErrMessage, response.AdmissionResponse.Result.Message)
}
})
}
}

type mockConfigEntryLister struct {
Resources []ConfigEntryResource
}

func (in *mockConfigEntryLister) List(_ context.Context) ([]ConfigEntryResource, error) {
return in.Resources, nil
}

type mockConfigEntry struct {
MockName string
MockNamespace string
Valid bool
}

func (in *mockConfigEntry) GetObjectMeta() metav1.ObjectMeta {
return metav1.ObjectMeta{}
}

func (in *mockConfigEntry) GetObjectKind() schema.ObjectKind {
return schema.EmptyObjectKind
}

func (in *mockConfigEntry) DeepCopyObject() runtime.Object {
return in
}

func (in *mockConfigEntry) AddFinalizer(_ string) {}

func (in *mockConfigEntry) RemoveFinalizer(_ string) {}

func (in *mockConfigEntry) Finalizers() []string {
return nil
}

func (in *mockConfigEntry) ConsulKind() string {
return "mock-kind"
}

func (in *mockConfigEntry) KubeKind() string {
return "mockkind"
}

func (in *mockConfigEntry) Name() string {
return in.MockName
}

func (in *mockConfigEntry) SetSyncedCondition(_ corev1.ConditionStatus, _ string, _ string) {}

func (in *mockConfigEntry) SyncedCondition() (status corev1.ConditionStatus, reason string, message string) {
return corev1.ConditionTrue, "", ""
}

func (in *mockConfigEntry) SyncedConditionStatus() corev1.ConditionStatus {
return corev1.ConditionTrue
}

func (in *mockConfigEntry) ToConsul() capi.ConfigEntry {
return &capi.ServiceConfigEntry{}
}

func (in *mockConfigEntry) Validate() error {
if !in.Valid {
return errors.New("invalid")
}
return nil
}

func (in *mockConfigEntry) MatchesConsul(_ capi.ConfigEntry) bool {
return false
}
2 changes: 2 additions & 0 deletions api/v1alpha1/groupversion_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/scheme"
)

const ConsulHashicorpGroup string = "consul.hashicorp.com"

var (
// GroupVersion is group version used to register these objects
GroupVersion = schema.GroupVersion{Group: "consul.hashicorp.com", Version: "v1alpha1"}
Expand Down
Loading

0 comments on commit d62b005

Please sign in to comment.