diff --git a/pkg/client/cache.go b/pkg/client/cache.go new file mode 100644 index 0000000000..9bcf2d10bc --- /dev/null +++ b/pkg/client/cache.go @@ -0,0 +1,308 @@ +package client + +import ( + "context" + "fmt" + "reflect" + + "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/selection" + "k8s.io/client-go/tools/cache" + + "github.com/kubernetes-sigs/kubebuilder/pkg/informer" + logf "github.com/kubernetes-sigs/kubebuilder/pkg/log" +) + +var log = logf.KBLog.WithName("object-cache") + +// ObjectCache is a ReadInterface +var _ ReadInterface = &ObjectCache{} + +type ObjectCache struct { + cachesByType map[reflect.Type]*SingleObjectCache + scheme *runtime.Scheme +} + +func ObjectCacheFromInformers(informers map[schema.GroupVersionKind]cache.SharedIndexInformer, scheme *runtime.Scheme) *ObjectCache { + res := NewObjectCache(scheme) + res.AddInformers(informers) + return res +} + +func (o *ObjectCache) AddInformers(informers map[schema.GroupVersionKind]cache.SharedIndexInformer) { + for gvk, informer := range informers { + o.AddInformer(gvk, informer) + } +} + +func (o *ObjectCache) Call(gvk schema.GroupVersionKind, c cache.SharedIndexInformer) { + o.AddInformer(gvk, c) +} + +func (o *ObjectCache) AddInformer(gvk schema.GroupVersionKind, c cache.SharedIndexInformer) { + obj, err := o.scheme.New(gvk) + if err != nil { + log.Error(err, "could not register informer in ObjectCache for GVK", "GroupVersionKind", gvk) + return + } + if _, found := o.CacheFor(obj); found { + return + } + o.RegisterCache(obj, gvk, c.GetIndexer()) +} + +func NewObjectCache(scheme *runtime.Scheme) *ObjectCache { + return &ObjectCache{ + cachesByType: make(map[reflect.Type]*SingleObjectCache), + scheme: scheme, + } +} + +func (c *ObjectCache) RegisterCache(obj runtime.Object, gvk schema.GroupVersionKind, store cache.Indexer) { + objType := reflect.TypeOf(obj) + c.cachesByType[objType] = &SingleObjectCache{ + Indexer: store, + GroupVersionKind: gvk, + } +} + +func (c *ObjectCache) CacheFor(obj runtime.Object) (*SingleObjectCache, bool) { + objType := reflect.TypeOf(obj) + cache, isKnown := c.cachesByType[objType] + return cache, isKnown +} + +func (c *ObjectCache) Get(ctx context.Context, key ObjectKey, out runtime.Object) error { + cache, isKnown := c.CacheFor(out) + if !isKnown { + return fmt.Errorf("no cache for objects of type %T, must have asked for an watch/informer first", out) + } + return cache.Get(ctx, key, out) +} + +func (c *ObjectCache) List(ctx context.Context, opts *ListOptions, out runtime.Object) error { + itemsPtr, err := apimeta.GetItemsPtr(out) + if err != nil { + return nil + } + // http://knowyourmeme.com/memes/this-is-fine + outType := reflect.Indirect(reflect.ValueOf(itemsPtr)).Type().Elem() + cache, isKnown := c.cachesByType[outType] + if !isKnown { + return fmt.Errorf("no cache for objects of type %T", out) + } + return cache.List(ctx, opts, out) +} + +// SingleObjectCache is a ReadInterface +var _ ReadInterface = &SingleObjectCache{} + +// SingleObjectCache is a ReadInterface that retrieves objects +// from a single local cache populated by a watch. +type SingleObjectCache struct { + // Indexer is the underlying indexer wrapped by this cache. + Indexer cache.Indexer + // GroupVersionKind is the group-version-kind of the resource. + GroupVersionKind schema.GroupVersionKind +} + +func (c *SingleObjectCache) Get(_ context.Context, key ObjectKey, out runtime.Object) error { + storeKey := objectKeyToStoreKey(key) + obj, exists, err := c.Indexer.GetByKey(storeKey) + if err != nil { + return err + } + if !exists { + // Resource gets transformed into Kind in the error anyway, so this is fine + return errors.NewNotFound(schema.GroupResource{ + Group: c.GroupVersionKind.Group, + Resource: c.GroupVersionKind.Kind, + }, key.Name) + } + if _, isObj := obj.(runtime.Object); !isObj { + return fmt.Errorf("cache contained %T, which is not an Object", obj) + } + + // deep copy to avoid mutating cache + // TODO(directxman12): revisit the decision to always deepcopy + obj = obj.(runtime.Object).DeepCopyObject() + + // TODO(directxman12): this is a terrible hack, pls fix + // (we should have deepcopyinto) + outVal := reflect.ValueOf(out) + objVal := reflect.ValueOf(obj) + if !objVal.Type().AssignableTo(outVal.Type()) { + return fmt.Errorf("cache had type %s, but %s was asked for", objVal.Type(), outVal.Type()) + } + reflect.Indirect(outVal).Set(reflect.Indirect(objVal)) + return nil +} + +func (c *SingleObjectCache) List(ctx context.Context, opts *ListOptions, out runtime.Object) error { + var objs []interface{} + var err error + + if opts != nil && opts.FieldSelector != nil { + // TODO(directxman12): support more complicated field selectors by + // combining multiple indicies, GetIndexers, etc + field, val, requiresExact := requiresExactMatch(opts.FieldSelector) + if !requiresExact { + return fmt.Errorf("non-exact field matches are not supported by the cache") + } + // list all objects by the field selector. If this is namespaced and we have one, ask for the + // namespaced index key. Otherwise, ask for the non-namespaced variant by using the fake "all namespaces" + // namespace. + objs, err = c.Indexer.ByIndex(fieldIndexName(field), keyToNamespacedKey(opts.Namespace, val)) + } else if opts != nil && opts.Namespace != "" { + objs, err = c.Indexer.ByIndex(cache.NamespaceIndex, opts.Namespace) + } else { + objs = c.Indexer.List() + } + if err != nil { + return err + } + var labelSel labels.Selector + if opts != nil && opts.LabelSelector != nil { + labelSel = opts.LabelSelector + } + + outItems := make([]runtime.Object, 0, len(objs)) + for _, item := range objs { + obj, isObj := item.(runtime.Object) + if !isObj { + return fmt.Errorf("cache contained %T, which is not an Object", obj) + } + meta, err := apimeta.Accessor(obj) + if err != nil { + return err + } + if labelSel != nil { + lbls := labels.Set(meta.GetLabels()) + if !labelSel.Matches(lbls) { + continue + } + } + outItems = append(outItems, obj.DeepCopyObject()) + } + if err := apimeta.SetList(out, outItems); err != nil { + return err + } + return nil +} + +// TODO: Make an interface with this function that has an Informers as an object on the struct +// that automatically calls InformerFor and passes in the Indexer into IndexByField + +// noNamespaceNamespace is used as the "namespace" when we want to list across all namespaces +const allNamespacesNamespace = "__all_namespaces" + +type InformerFieldIndexer struct { + Informers informer.Informers +} + +func (i *InformerFieldIndexer) IndexField(obj runtime.Object, field string, extractValue IndexerFunc) error { + informer, err := i.Informers.InformerFor(obj) + if err != nil { + return err + } + return IndexByField(informer.GetIndexer(), field, extractValue) +} + +// IndexByField adds an indexer to the underlying cache, using extraction function to get +// value(s) from the given field. This index can then be used by passing a field selector +// to List. For one-to-one compatibility with "normal" field selectors, only return one value. +// The values may be anything. They will automatically be prefixed with the namespace of the +// given object, if present. The objects passed are guaranteed to be objects of the correct type. +func IndexByField(indexer cache.Indexer, field string, extractor IndexerFunc) error { + indexFunc := func(objRaw interface{}) ([]string, error) { + // TODO(directxman12): check if this is the correct type? + obj, isObj := objRaw.(runtime.Object) + if !isObj { + return nil, fmt.Errorf("object of type %T is not an Object", objRaw) + } + meta, err := apimeta.Accessor(obj) + if err != nil { + return nil, err + } + ns := meta.GetNamespace() + + rawVals := extractor(obj) + var vals []string + if ns == "" { + // if we're not doubling the keys for the namespaced case, just re-use what was returned to us + vals = rawVals + } else { + // if we need to add non-namespaced versions too, double the length + vals = make([]string, len(rawVals)*2) + } + for i, rawVal := range rawVals { + // save a namespaced variant, so that we can ask + // "what are all the object matching a given index *in a given namespace*" + vals[i] = keyToNamespacedKey(ns, rawVal) + if ns != "" { + // if we have a namespace, also inject a special index key for listing + // regardless of the object namespace + vals[i+len(rawVals)] = keyToNamespacedKey("", rawVal) + } + } + + return vals, nil + } + + if err := indexer.AddIndexers(cache.Indexers{fieldIndexName(field): indexFunc}); err != nil { + return err + } + return nil +} + +// fieldIndexName constructs the name of the index over the given field, +// for use with an Indexer. +func fieldIndexName(field string) string { + return "field:" + field +} + +// keyToNamespacedKey prefixes the given index key with a namespace +// for use in field selector indexes. +func keyToNamespacedKey(ns string, baseKey string) string { + if ns != "" { + return ns + "/" + baseKey + } + return allNamespacesNamespace + "/" + baseKey +} + +// objectKeyToStorageKey converts an object key to store key. +// It's akin to MetaNamespaceKeyFunc. It's seperate from +// String to allow keeping the key format easily in sync with +// MetaNamespaceKeyFunc. +func objectKeyToStoreKey(k ObjectKey) string { + if k.Namespace == "" { + return k.Name + } + return k.Namespace + "/" + k.Name +} + +// requiresExactMatch checks if the given field selector is of the form `k=v` or `k==v`. +func requiresExactMatch(sel fields.Selector) (field, val string, required bool) { + reqs := sel.Requirements() + if len(reqs) != 1 { + return "", "", false + } + req := reqs[0] + if req.Operator != selection.Equals && req.Operator != selection.DoubleEquals { + return "", "", false + } + return req.Field, req.Value, true +} + +// SplitReaderWriter forms an interface Interface by composing separate +// read and write interfaces. This way, you can have an Interface that +// reads from a cache and writes to the API server. +type SplitReaderWriter struct { + ReadInterface + WriteInterface +} diff --git a/pkg/client/cache_test.go b/pkg/client/cache_test.go new file mode 100644 index 0000000000..ee7c6adeb6 --- /dev/null +++ b/pkg/client/cache_test.go @@ -0,0 +1,172 @@ +package client_test + +import ( + "context" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + kapi "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/cache" + + . "github.com/kubernetes-sigs/kubebuilder/pkg/client" +) + +var _ = Describe("Indexers", func() { + three := int64(3) + knownPodKey := ObjectKey{Name: "some-pod", Namespace: "some-ns"} + knownPod3Key := ObjectKey{Name: "some-pod", Namespace: "some-other-ns"} + knownVolumeKey := ObjectKey{Name: "some-vol", Namespace: "some-ns"} + knownPod := &kapi.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: knownPodKey.Name, + Namespace: knownPodKey.Namespace, + }, + Spec: kapi.PodSpec{ + RestartPolicy: kapi.RestartPolicyNever, + ActiveDeadlineSeconds: &three, + }, + } + knownPod2 := &kapi.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: knownVolumeKey.Name, + Namespace: knownVolumeKey.Namespace, + Labels: map[string]string{ + "somelbl": "someval", + }, + }, + Spec: kapi.PodSpec{ + RestartPolicy: kapi.RestartPolicyAlways, + }, + } + knownPod3 := &kapi.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: knownPod3Key.Name, + Namespace: knownPod3Key.Namespace, + Labels: map[string]string{ + "somelbl": "someval", + }, + }, + Spec: kapi.PodSpec{ + RestartPolicy: kapi.RestartPolicyNever, + }, + } + knownVolume := &kapi.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{ + Name: knownVolumeKey.Name, + Namespace: knownVolumeKey.Namespace, + }, + } + var multiCache *ObjectCache + + BeforeEach(func() { + multiCache = NewObjectCache() + podIndexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{ + cache.NamespaceIndex: cache.MetaNamespaceIndexFunc, + }) + volumeIndexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{ + cache.NamespaceIndex: cache.MetaNamespaceIndexFunc, + }) + IndexByField(podIndexer, "spec.restartPolicy", func(obj runtime.Object) []string { + return []string{string(obj.(*kapi.Pod).Spec.RestartPolicy)} + }) + Expect(podIndexer.Add(knownPod)).NotTo(HaveOccurred()) + Expect(podIndexer.Add(knownPod2)).NotTo(HaveOccurred()) + Expect(podIndexer.Add(knownPod3)).NotTo(HaveOccurred()) + Expect(volumeIndexer.Add(knownVolume)).NotTo(HaveOccurred()) + multiCache.RegisterCache(&kapi.Pod{}, kapi.SchemeGroupVersion.WithKind("Pod"), podIndexer) + multiCache.RegisterCache(&kapi.PersistentVolume{}, kapi.SchemeGroupVersion.WithKind("PersistentVolume"), volumeIndexer) + }) + + Describe("client interface wrapper around an indexer", func() { + var singleCache ReadInterface + + BeforeEach(func() { + var ok bool + singleCache, ok = multiCache.CacheFor(&kapi.Pod{}) + Expect(ok).To(BeTrue()) + }) + + It("should be able to fetch a particular object by key", func() { + out := kapi.Pod{} + Expect(singleCache.Get(context.TODO(), knownPodKey, &out)).NotTo(HaveOccurred()) + Expect(&out).To(Equal(knownPod)) + }) + + It("should error out for missing objects", func() { + Expect(singleCache.Get(context.TODO(), ObjectKey{Name: "unkown-pod"}, &kapi.Pod{})).To(HaveOccurred()) + }) + + It("should be able to list objects by namespace", func() { + out := kapi.PodList{} + Expect(singleCache.List(context.TODO(), InNamespace(knownPodKey.Namespace), &out)).NotTo(HaveOccurred()) + Expect(out.Items).To(ConsistOf(*knownPod, *knownPod2)) + }) + + It("should error out if the incorrect object type is passed for this indexer", func() { + Expect(singleCache.Get(context.TODO(), knownPodKey, &kapi.PersistentVolume{})).To(HaveOccurred()) + }) + + It("should deep copy the object unless told otherwise", func() { + out := kapi.Pod{} + Expect(singleCache.Get(context.TODO(), knownPodKey, &out)).NotTo(HaveOccurred()) + Expect(&out).To(Equal(knownPod)) + + *out.Spec.ActiveDeadlineSeconds = 4 + Expect(*out.Spec.ActiveDeadlineSeconds).NotTo(Equal(*knownPod.Spec.ActiveDeadlineSeconds)) + }) + + It("should support filtering by labels", func() { + out := kapi.PodList{} + Expect(singleCache.List(context.TODO(), InNamespace(knownPodKey.Namespace).MatchingLabels(map[string]string{"somelbl": "someval"}), &out)).NotTo(HaveOccurred()) + Expect(out.Items).To(ConsistOf(*knownPod2)) + }) + + It("should support filtering by a single field=value specification, if previously indexed", func() { + By("listing by field selector in a namespace") + out := kapi.PodList{} + Expect(singleCache.List(context.TODO(), InNamespace(knownPodKey.Namespace).MatchingField("spec.restartPolicy", "Always"), &out)).NotTo(HaveOccurred()) + Expect(out.Items).To(ConsistOf(*knownPod2)) + + By("listing by field selector across all namespaces") + Expect(singleCache.List(context.TODO(), MatchingField("spec.restartPolicy", "Never"), &out)).NotTo(HaveOccurred()) + Expect(out.Items).To(ConsistOf(*knownPod, *knownPod3)) + }) + }) + + Describe("client interface wrapper around multiple indexers", func() { + It("should be able to fetch any known object by key and type", func() { + outPod := kapi.Pod{} + Expect(multiCache.Get(context.TODO(), knownPodKey, &outPod)).NotTo(HaveOccurred()) + Expect(&outPod).To(Equal(knownPod)) + + outVol := kapi.PersistentVolume{} + Expect(multiCache.Get(context.TODO(), knownVolumeKey, &outVol)).NotTo(HaveOccurred()) + Expect(&outVol).To(Equal(knownVolume)) + }) + + It("should error out if the object type is unknown", func() { + Expect(multiCache.Get(context.TODO(), knownPodKey, &kapi.PersistentVolumeClaim{})).To(HaveOccurred()) + }) + + It("should deep copy the object unless told otherwise", func() { + out := kapi.Pod{} + Expect(multiCache.Get(context.TODO(), knownPodKey, &out)).NotTo(HaveOccurred()) + Expect(&out).To(Equal(knownPod)) + + *out.Spec.ActiveDeadlineSeconds = 4 + Expect(*out.Spec.ActiveDeadlineSeconds).NotTo(Equal(*knownPod.Spec.ActiveDeadlineSeconds)) + }) + + It("should be able to fetch single caches for known types", func() { + indexer, ok := multiCache.CacheFor(&kapi.Pod{}) + Expect(ok).To(BeTrue()) + Expect(indexer).NotTo(BeNil()) + + _, ok2 := multiCache.CacheFor(&kapi.PersistentVolumeClaim{}) + Expect(ok2).To(BeFalse()) + }) + }) +}) diff --git a/pkg/client/client.go b/pkg/client/client.go new file mode 100644 index 0000000000..aa460cd0a3 --- /dev/null +++ b/pkg/client/client.go @@ -0,0 +1,175 @@ +package client + +import ( + "context" + "reflect" + "sync" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/common" +) + +var _ Interface = &Client{} + +// client is an Interface that works by reading and writing +// directly from/to an API server. +type Client struct { + Config *rest.Config + Scheme *runtime.Scheme + Mapper meta.RESTMapper + + once sync.Once + codecs serializer.CodecFactory + paramCodec runtime.ParameterCodec + clientsByType map[reflect.Type]rest.Interface + resourcesByType map[reflect.Type]string + mu sync.RWMutex +} + +// TODO: pass discovery info down from controller manager + +func (c *Client) init() { + c.once.Do(func() { + // Init a scheme if none provided + if c.Scheme == nil { + c.Scheme = scheme.Scheme + } + + // Setup the codecs + c.codecs = serializer.NewCodecFactory(c.Scheme) + c.paramCodec = runtime.NewParameterCodec(c.Scheme) + c.clientsByType = make(map[reflect.Type]rest.Interface) + c.resourcesByType = make(map[reflect.Type]string) + }) +} + +func (c *Client) makeClient(obj runtime.Object) (rest.Interface, string, error) { + gvk, err := common.GVKForObject(obj, c.Scheme) + if err != nil { + return nil, "", err + } + client, err := common.RESTClientForGVK(gvk, c.Config, c.codecs) + if err != nil { + return nil, "", err + } + mapping, err := c.Mapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return nil, "", err + } + return client, mapping.Resource, nil +} + +// ClientFor returns a raw rest.Interface for the given object type. +func (c *Client) clientFor(obj runtime.Object) (rest.Interface, string, error) { + c.init() + typ := reflect.TypeOf(obj) + + // It's better to do creation work twice than to not let multiple + // people make requests at once + c.mu.RLock() + client, known := c.clientsByType[typ] + resource, _ := c.resourcesByType[typ] + c.mu.RUnlock() + + if !known { + var err error + client, resource, err = c.makeClient(obj) + if err != nil { + return nil, "", err + } + c.mu.Lock() + defer c.mu.Unlock() + c.clientsByType[typ] = client + c.resourcesByType[typ] = resource + } + + return client, resource, nil +} + +func (c *Client) Create(ctx context.Context, obj runtime.Object) error { + client, resource, err := c.clientFor(obj) + if err != nil { + return err + } + meta, err := meta.Accessor(obj) + if err != nil { + return err + } + return client.Post(). + Namespace(meta.GetNamespace()). + Resource(resource). + Body(obj). + Do(). + Into(obj) +} + +func (c *Client) Update(ctx context.Context, obj runtime.Object) error { + client, resource, err := c.clientFor(obj) + if err != nil { + return err + } + meta, err := meta.Accessor(obj) + if err != nil { + return err + } + return client.Put(). + Namespace(meta.GetNamespace()). + Resource(resource). + Name(meta.GetName()). + Body(obj). + Do(). + Into(obj) +} + +func (c *Client) Delete(ctx context.Context, obj runtime.Object) error { + client, resource, err := c.clientFor(obj) + if err != nil { + return err + } + meta, err := meta.Accessor(obj) + if err != nil { + return err + } + return client.Delete(). + Namespace(meta.GetNamespace()). + Resource(resource). + Name(meta.GetName()). + Do(). + Error() +} + +func (c *Client) Get(ctx context.Context, key ObjectKey, obj runtime.Object) error { + client, resource, err := c.clientFor(obj) + if err != nil { + return err + } + return client.Get(). + Namespace(key.Namespace). + Resource(resource). + Name(key.Name). + Do(). + Into(obj) +} + +func (c *Client) List(ctx context.Context, opts *ListOptions, obj runtime.Object) error { + client, resource, err := c.clientFor(obj) + if err != nil { + return err + } + ns := "" + if opts != nil { + ns = opts.Namespace + } + return client.Get(). + Namespace(ns). + Resource(resource). + Body(obj). + VersionedParams(opts.AsListOptions(), c.paramCodec). + Do(). + Into(obj) +} diff --git a/pkg/client/client_suite_test.go b/pkg/client/client_suite_test.go new file mode 100644 index 0000000000..f62ef3c112 --- /dev/null +++ b/pkg/client/client_suite_test.go @@ -0,0 +1,13 @@ +package client_test + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestEventhandler(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "client tests") +} diff --git a/pkg/client/interfaces.go b/pkg/client/interfaces.go new file mode 100644 index 0000000000..e10e5c31a3 --- /dev/null +++ b/pkg/client/interfaces.go @@ -0,0 +1,164 @@ +package client + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/fields" +) + +// ObjectKey identifies a Kubernetes Object. +type ObjectKey = types.NamespacedName + +// TODO(directxman12): is there a sane way to deal with get/delete options? + +// ReadInterface knows how to read and list Kubernetes objects. +type ReadInterface interface { + // 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 ObjectKey, obj runtime.Object) error + + // List retrieves list of objects for a given namespace and list options. On a + // successful call, Items field in the list will be populated with the + // result returned from the server. + List(ctx context.Context, opts *ListOptions, list runtime.Object) error + +} + +// WriteInterface knows how to create, delete, and update Kubernetes objects. +type WriteInterface interface { + // Create saves the object obj in the Kubernetes cluster. + Create(ctx context.Context, obj runtime.Object) error + + // Delete deletes the given obj from Kubernetes cluster. + Delete(ctx context.Context, obj runtime.Object) error + + // Update updates the given obj in the Kubernetes cluster. obj must be a + // struct pointer so that obj can be updated with the content returned by the Server. + Update(ctx context.Context, obj runtime.Object) error +} + + +// Interface knows how to perform CRUD operations on Kubernetes objects. +type Interface interface { + ReadInterface + WriteInterface +} + +// IndexerFunc knows how to take an object and turn it into a series +// of (non-namespaced) keys for that object. +type IndexerFunc func(runtime.Object) []string + +// FieldIndexer knows how to index over a particular "field" such that it +// can later be used by a field selector. +type FieldIndexer interface { + // IndexFields adds an index with the given field name on the given object type + // by using the given function to extract the value for that field. If you want + // compatibility with the Kubernetes API server, only return one key, and only use + // fields that the API server supports. Otherwise, you can return multiple keys, + // and "equality" in the field selector means that at least one key matches the value. + IndexField(obj runtime.Object, field string, extractValue IndexerFunc) error +} + +// ListOptions contains options for limitting or filtering results. +// It's generally a subset of metav1.ListOptions, with support for +// pre-parsed selectors (since generally, selectors will be executed +// against the cache). +type ListOptions struct { + // LabelSelector filters results by label. Use SetLabelSelector to + // set from raw string form. + LabelSelector labels.Selector + // FieldSelector filters results by a particular field. In order + // to use this with cache-based implementations, restrict usage to + // a single field-value pair that's been added to the indexers. + FieldSelector fields.Selector + + // Namespace represents the namespace to list for, or empty for + // non-namespaced objects, or to list across all namespaces. + Namespace string + + // Raw represents raw ListOptions, as passed to the API server. Note + // that these may not be respsected by all implementations of interface, + // and the LabelSelector and FieldSelector fields are ignored. + Raw *metav1.ListOptions +} + +// SetLabelSelector sets this the label selector of these options +// from a string form of the selector. +func (o *ListOptions) SetLabelSelector(selRaw string) error { + sel, err := labels.Parse(selRaw) + if err != nil { + return err + } + o.LabelSelector = sel + return nil +} + +// SetFieldSelector sets this the label selector of these options +// from a string form of the selector. +func (o *ListOptions) SetFieldSelector(selRaw string) error { + sel, err := fields.ParseSelector(selRaw) + if err != nil { + return err + } + o.FieldSelector = sel + return nil +} + +// AsListOptions returns these options as a flattened metav1.ListOptions. +// This may mutate the Raw field. +func (o *ListOptions) AsListOptions() *metav1.ListOptions { + if o == nil { + return nil + } + o.Raw.LabelSelector = o.LabelSelector.String() + o.Raw.FieldSelector = o.FieldSelector.String() + return o.Raw +} + +// MatchingLabels is a convenience function that sets the label selector +// to match the given labels, and then returns the options. +// It mutates the list options. +func (o *ListOptions) MatchingLabels(lbls map[string]string) *ListOptions { + sel := labels.SelectorFromSet(lbls) + o.LabelSelector = sel + return o +} + +// MatchingLabels is a convenience function that sets the field selector +// to match the given field, and then returns the options. +// It mutates the list options. +func (o *ListOptions) MatchingField(name, val string) *ListOptions { + sel := fields.SelectorFromSet(fields.Set{name: val}) + o.FieldSelector = sel + return o +} + +// InNamespace is a convenience function that sets the namespace, +// and then returns the options. It mutates the list options. +func (o *ListOptions) InNamespace(ns string) *ListOptions { + o.Namespace = ns + return o +} + +// MatchingLabels is a convenience function that constructs list options +// to match the given labels. +func MatchingLabels(lbls map[string]string) *ListOptions { + return (&ListOptions{}).MatchingLabels(lbls) +} + +// MatchingLabels is a convenience function that constructs list options +// to match the given field. +func MatchingField(name, val string) *ListOptions { + return (&ListOptions{}).MatchingField(name, val) +} + +// InNamespace is a convenience function that constructs list +// options to list in the given namespace. +func InNamespace(ns string) *ListOptions { + return (&ListOptions{}).InNamespace(ns) +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000000..202e541e6a --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,139 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +import ( + "flag" + "fmt" + "log" + "os" + "os/user" + "path/filepath" + "time" + + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +var ( + kubeconfig, masterURL string +) + +func init() { + // TODO: Fix this to allow double vendoring this library but still register flags on behalf of users + flag.StringVar(&kubeconfig, "kubeconfig", "", + "Path to a kubeconfig. Only required if out-of-cluster.") + + flag.StringVar(&masterURL, "master", "", + "The address of the Kubernetes API server. Overrides any value in kubeconfig. "+ + "Only required if out-of-cluster.") +} + +// GetConfig creates a *rest.Config for talking to a Kubernetes apiserver. +// If --kubeconfig is set, will use the kubeconfig file at that location. Otherwise will assume running +// in cluster and use the cluster provided kubeconfig. +// +// Will log.Fatal if KubernetesInformers cannot be created +func GetConfig() (*rest.Config, error) { + // If a flag is specified with the config location, use that + if len(kubeconfig) > 0 { + return clientcmd.BuildConfigFromFlags(masterURL, kubeconfig) + } + + // If an env variable is specified with the config locaiton, use that + if len(os.Getenv("KUBECONFIG")) > 0 { + return clientcmd.BuildConfigFromFlags(masterURL, kubeconfig) + } + // If no explicit location, try the in-cluster config + if c, err := rest.InClusterConfig(); err == nil { + return c, nil + } + // If no in-cluster config, try the default location in the user's home directory + if usr, err := user.Current(); err == nil { + if c, err := clientcmd.BuildConfigFromFlags( + "", filepath.Join(usr.HomeDir, ".kube", "config")); err == nil { + return c, nil + } + } + + return nil, fmt.Errorf("could not locate a kubeconfig") +} + +// GetConfig creates a *rest.Config for talking to a Kubernetes apiserver. +// If --kubeconfig is set, will use the kubeconfig file at that location. Otherwise will assume running +// in cluster and use the cluster provided kubeconfig. +func GetConfigOrDie() *rest.Config { + config, err := GetConfig() + if err != nil { + log.Fatalf("%v", err) + } + return config +} + +// GetKubernetesClientSet creates a *kubernetes.ClientSet for talking to a Kubernetes apiserver. +// If --kubeconfig is set, will use the kubeconfig file at that location. Otherwise will assume running +// in cluster and use the cluster provided kubeconfig. +func GetKubernetesClientSet() (*kubernetes.Clientset, error) { + config, err := GetConfig() + if err != nil { + return nil, err + } + return kubernetes.NewForConfig(config) +} + +// GetKubernetesClientSetOrDie creates a *kubernetes.ClientSet for talking to a Kubernetes apiserver. +// If --kubeconfig is set, will use the kubeconfig file at that location. Otherwise will assume running +// in cluster and use the cluster provided kubeconfig. +// +// Will log.Fatal if KubernetesInformers cannot be created +func GetKubernetesClientSetOrDie() (*kubernetes.Clientset, error) { + cs, err := GetKubernetesClientSet() + if err != nil { + log.Fatalf("%v", err) + } + return cs, nil +} + +// GetKubernetesInformers creates a informers.SharedInformerFactory for talking to a Kubernetes apiserver. +// If --kubeconfig is set, will use the kubeconfig file at that location. Otherwise will assume running +// in cluster and use the cluster provided kubeconfig. +func GetKubernetesInformers() (informers.SharedInformerFactory, error) { + config, err := GetConfig() + if err != nil { + return nil, err + } + i, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, err + } + return informers.NewSharedInformerFactory(i, time.Minute*5), nil +} + +// GetKubernetesInformers creates a informers.SharedInformerFactory for talking to a Kubernetes apiserver. +// If --kubeconfig is set, will use the kubeconfig file at that location. Otherwise will assume running +// in cluster and use the cluster provided kubeconfig. +// +// Will log.Fatal if KubernetesInformers cannot be created +func GetKubernetesInformersOrDie() informers.SharedInformerFactory { + i, err := GetKubernetesInformers() + if err != nil { + log.Fatalf("%v", err) + } + return i +} diff --git a/pkg/config/doc.go b/pkg/config/doc.go new file mode 100644 index 0000000000..eb024174ee --- /dev/null +++ b/pkg/config/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// The config package contains libraries for initializing rest configs for talking to the Kubernetes API +package config diff --git a/pkg/controller/common_suite_test.go b/pkg/controller/common_suite_test.go new file mode 100644 index 0000000000..434350bc63 --- /dev/null +++ b/pkg/controller/common_suite_test.go @@ -0,0 +1,29 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "testing" +) + +func TestController(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "controller Suite") +} diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go new file mode 100644 index 0000000000..3bc7f16d17 --- /dev/null +++ b/pkg/controller/controller.go @@ -0,0 +1,338 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "fmt" + "log" + "sort" + "sync" + "sync/atomic" + "time" + + "github.com/golang/glog" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/eventhandlers" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/informers" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/metrics" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/predicates" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/types" + "github.com/kubernetes-sigs/kubebuilder/pkg/inject/run" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" +) + +var ( + // DefaultReconcileFn is used by GenericController if reconcile is not set + DefaultReconcileFn = func(k types.ReconcileKey) error { + log.Printf("No ReconcileFn defined - skipping %+v", k) + return nil + } + + counter uint64 +) + +// Code originally copied from kubernetes/sample-controller at +// https://github.com/kubernetes/sample-controller/blob/994cb3621c790e286ab11fb74b3719b20bb55ca7/controller.go + +// GenericController watches event sources and invokes a reconcile function +type GenericController struct { + // Name is the name of the controller + Name string + + // reconcile implements the controller business logic. + Reconcile types.ReconcileFn + + // informerProvider contains the registry of shared informers to use. + InformerRegistry informers.InformerGetter + + BeforeReconcile func(key types.ReconcileKey) + AfterReconcile func(key types.ReconcileKey, err error) + + // listeningQueue is an listeningQueue that listens for events from informers and adds object keys to + // the queue for processing + queue listeningQueue + + // syncTs contains the start times of each currently running reconcile loop + syncTs sets.Int64 + + // once ensures unspecified fields get default values + once sync.Once +} + +// GetMetrics returns metrics about the queue processing +func (gc *GenericController) GetMetrics() metrics.Metrics { + // Get the current timestamps and sort them + ts := gc.syncTs.List() + sort.Slice(ts, func(i, j int) bool { return ts[i] < ts[j] }) + return metrics.Metrics{ + UncompletedReconcileTs: ts, + QueueLength: gc.queue.Len(), + } +} + +// Watch watches objects matching obj's type and enqueues their keys to be reconcild. +func (gc *GenericController) Watch(obj metav1.Object, p ...predicates.Predicate) error { + gc.once.Do(gc.init) + return gc.queue.addEventHandler(obj, + eventhandlers.MapAndEnqueue{Map: eventhandlers.MapToSelf, Predicates: p}) +} + +// WatchControllerOf reconciles the controller of the object type being watched. e.g. If the +// controller created a Pod, watch the Pod for events and invoke the controller reconcile function. +// Uses path to lookup the ancestors. Will lookup each ancestor in the path until it gets to the +// root and then reconcile this key. +// +// Example: Deployment controller creates a ReplicaSet. ReplicaSet controller creates a Pod. Deployment +// controller wants to have its reconcile method called for Pod events for any Pods it created (transitively). +// - Pod event occurs - find owners references +// - Lookup the Pod parent ReplicaSet by using the first path element (compare UID to ref) +// - Lookup the ReplicaSet parent Deployment by using the second path element (compare UID to ref) +// - Enqueue reconcile for Deployment namespace/name +// +// This could be implemented as: +// WatchControllerOf(&corev1.Pod, eventhandlers.Path{FnToLookupReplicaSetByNamespaceName, FnToLookupDeploymentByNamespaceName }) +func (gc *GenericController) WatchControllerOf(obj metav1.Object, path eventhandlers.Path, + p ...predicates.Predicate) error { + gc.once.Do(gc.init) + return gc.queue.addEventHandler(obj, + eventhandlers.MapAndEnqueue{Map: eventhandlers.MapToController{Path: path}.Map, Predicates: p}) +} + +// WatchTransformationOf watches objects matching obj's type and enqueues the key returned by mapFn. +func (gc *GenericController) WatchTransformationOf(obj metav1.Object, mapFn eventhandlers.ObjToKey, + p ...predicates.Predicate) error { + gc.once.Do(gc.init) + return gc.queue.addEventHandler(obj, + eventhandlers.MapAndEnqueue{Map: mapFn, Predicates: p}) +} + +// WatchTransformationsOf watches objects matching obj's type and enqueues the keys returned by mapFn. +func (gc *GenericController) WatchTransformationsOf(obj metav1.Object, mapFn eventhandlers.ObjToKeys, + p ...predicates.Predicate) error { + gc.once.Do(gc.init) + return gc.queue.addEventHandler(obj, + eventhandlers.MapAndEnqueue{MultiMap: func(i interface{}) []types.ReconcileKey { + result := []types.ReconcileKey{} + for _, k := range mapFn(i) { + if namespace, name, err := cache.SplitMetaNamespaceKey(k); err == nil { + result = append(result, types.ReconcileKey{namespace, name}) + } + } + return result + }, Predicates: p}) +} + +// WatchTransformationKeyOf watches objects matching obj's type and enqueues the key returned by mapFn. +func (gc *GenericController) WatchTransformationKeyOf(obj metav1.Object, mapFn eventhandlers.ObjToReconcileKey, + p ...predicates.Predicate) error { + gc.once.Do(gc.init) + return gc.queue.addEventHandler(obj, + eventhandlers.MapAndEnqueue{MultiMap: func(i interface{}) []types.ReconcileKey { + if k := mapFn(i); len(k.Name) > 0 { + return []types.ReconcileKey{k} + } else { + return []types.ReconcileKey{} + } + }, Predicates: p}) +} + +// WatchTransformationKeysOf watches objects matching obj's type and enqueues the keys returned by mapFn. +func (gc *GenericController) WatchTransformationKeysOf(obj metav1.Object, mapFn eventhandlers.ObjToReconcileKeys, + p ...predicates.Predicate) error { + gc.once.Do(gc.init) + return gc.queue.addEventHandler(obj, + eventhandlers.MapAndEnqueue{MultiMap: mapFn, Predicates: p}) +} + +// WatchEvents watches objects matching obj's type and uses the functions from provider to handle events. +func (gc *GenericController) WatchEvents(obj metav1.Object, provider types.HandleFnProvider) error { + gc.once.Do(gc.init) + return gc.queue.addEventHandler(obj, fnToInterfaceAdapter{provider}) +} + +// WatchChannel enqueues object keys read from the channel. +func (gc *GenericController) WatchChannel(source <-chan string) error { + gc.once.Do(gc.init) + return gc.queue.watchChannel(source) +} + +// fnToInterfaceAdapter adapts a function to an interface +type fnToInterfaceAdapter struct { + val func(workqueue.RateLimitingInterface) cache.ResourceEventHandler +} + +func (f fnToInterfaceAdapter) Get(q workqueue.RateLimitingInterface) cache.ResourceEventHandler { + return f.val(q) +} + +// RunInformersAndControllers will set up the event handlers for types we are interested in, as well +// as syncing SharedIndexInformer caches and starting workers. It will block until stopCh +// is closed, at which point it will shutdown the workqueue and wait for +// workers to finish processing their current work items. +func (gc *GenericController) run(options run.RunArguments) error { + gc.once.Do(gc.init) + defer runtime.HandleCrash() + defer gc.queue.ShutDown() + + // Start the SharedIndexInformer factories to begin populating the SharedIndexInformer caches + glog.Infof("Starting %s controller", gc.Name) + + // Wait for the caches to be synced before starting workers + glog.Infof("Waiting for %s SharedIndexInformer caches to sync", gc.Name) + if ok := cache.WaitForCacheSync(options.Stop, gc.queue.synced...); !ok { + return fmt.Errorf("failed to wait for %s caches to sync", gc.Name) + } + + glog.Infof("Starting %s workers", gc.Name) + // Launch two workers to process resources + for i := 0; i < options.ControllerParallelism; i++ { + go wait.Until(gc.runWorker, time.Second, options.Stop) + } + + glog.Infof("Started %s workers", gc.Name) + <-options.Stop + glog.Infof("Shutting %s down workers", gc.Name) + + return nil +} + +func defaultWorkQueueProvider(name string) workqueue.RateLimitingInterface { + return workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), name) +} + +// init defaults field values on c +func (gc *GenericController) init() { + if gc.syncTs == nil { + gc.syncTs = sets.Int64{} + } + + if gc.InformerRegistry == nil { + gc.InformerRegistry = DefaultManager + } + + // Set the default reconcile fn to just print messages + if gc.Reconcile == nil { + gc.Reconcile = DefaultReconcileFn + } + + if len(gc.Name) == 0 { + gc.Name = fmt.Sprintf("controller-%d", atomic.AddUint64(&counter, 1)) + } + + // Default the queue name to match the controller name + if len(gc.queue.Name) == 0 { + gc.queue.Name = gc.Name + } + + // Default the RateLimitingInterface to a NamedRateLimitingQueue + if gc.queue.RateLimitingInterface == nil { + gc.queue.RateLimitingInterface = defaultWorkQueueProvider(gc.Name) + } + + // Set the InformerRegistry on the queue + gc.queue.informerProvider = gc.InformerRegistry +} + +// runWorker is a long-running function that will continually call the +// processNextWorkItem function in order to read and process a message on the +// workqueue. +func (gc *GenericController) runWorker() { + for gc.processNextWorkItem() { + } +} + +// processNextWorkItem will read a single work item off the workqueue and +// attempt to process it, by calling the syncHandler. +func (gc *GenericController) processNextWorkItem() bool { + obj, shutdown := gc.queue.Get() + + start := time.Now().Unix() + gc.syncTs.Insert(start) + defer gc.syncTs.Delete(start) + + if shutdown { + return false + } + + // We wrap this block in a func so we can defer c.workque ue.Done. + err := func(obj interface{}) error { + // We call Done here so the workqueue knows we have finished + // processing this item. We also must remember to call Forget if we + // do not want this work item being re-queued. For example, we do + // not call Forget if a transient error occurs, instead the item is + // put back on the workqueue and attempted again after a back-off + // period. + defer gc.queue.Done(obj) + var key string + var ok bool + // We expect strings to come off the workqueue. These are of the + // form namespace/Name. We do this as the delayed nature of the + // workqueue means the items in the SharedIndexInformer cache may actually be + // more up to date that when the item was initially put onto the + // workqueue. + if key, ok = obj.(string); !ok { + // As the item in the workqueue is actually invalid, we call + // Forget here else we'd go into a loop of attempting to + // process a work item that is invalid. + gc.queue.Forget(obj) + runtime.HandleError(fmt.Errorf("expected string in %s workqueue but got %#v", gc.Name, obj)) + return nil + } + namespace, name, err := cache.SplitMetaNamespaceKey(key) + if err != nil { + runtime.HandleError(fmt.Errorf("invalid resource key in %s queue: %s", gc.Name, key)) + return nil + } + + rk := types.ReconcileKey{ + Name: name, + Namespace: namespace, + } + if gc.BeforeReconcile != nil { + gc.BeforeReconcile(rk) + } + // RunInformersAndControllers the syncHandler, passing it the namespace/Name string of the + // resource to be synced. + if err = gc.Reconcile(rk); err != nil { + if gc.AfterReconcile != nil { + gc.AfterReconcile(rk, err) + } + gc.queue.AddRateLimited(key) + return fmt.Errorf("error syncing %s queue '%s': %s", gc.Name, key, err.Error()) + } + if gc.AfterReconcile != nil { + gc.AfterReconcile(rk, err) + } + + // Finally, if no error occurs we Forget this item so it does not + // get queued again until another change happens. + gc.queue.Forget(obj) + glog.Infof("Successfully synced %s queue '%s'", gc.Name, key) + return nil + }(obj) + + if err != nil { + runtime.HandleError(err) + return true + } + + return true +} diff --git a/pkg/controller/controller_test.go b/pkg/controller/controller_test.go new file mode 100644 index 0000000000..7fd256ff75 --- /dev/null +++ b/pkg/controller/controller_test.go @@ -0,0 +1,517 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "fmt" + "sync/atomic" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "time" + + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/eventhandlers" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/test" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/types" + "github.com/kubernetes-sigs/kubebuilder/pkg/inject/run" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" +) + +var _ = Describe("GenericController", func() { + var ( + instance *GenericController + mgr *ControllerManager + fakePodInformer *test.FakeInformer + fakeReplicaSetInformer *test.FakeInformer + result chan string + stop chan struct{} + ch chan string + t = true + ) + + BeforeEach(func() { + mgr = &ControllerManager{} + + // Create a new informers map with fake informers + fakePodInformer = &test.FakeInformer{Synced: true} + Expect(mgr.AddInformerProvider(&corev1.Pod{}, fakePodInformer)).To(Succeed()) + + // Don't allow inserting the same informer 2x + Expect(mgr.AddInformerProvider(&corev1.Pod{}, fakePodInformer)).To(Not(Succeed())) + + fakeReplicaSetInformer = &test.FakeInformer{Synced: true} + Expect(mgr.AddInformerProvider(&appsv1.ReplicaSet{}, fakeReplicaSetInformer)).To(Succeed()) + + result = make(chan string) + stop = make(chan struct{}) + }) + + Describe("Watching a Pod from a controller", func() { + BeforeEach(func() { + // Create a new listeningQueue + instance = &GenericController{ + Name: "TestInstance", + InformerRegistry: mgr, + Reconcile: func(k types.ReconcileKey) error { + // Write the result to a channel + result <- k.Namespace + "/" + k.Name + return nil + }, + } + mgr.AddController(instance) + mgr.RunInformersAndControllers(run.RunArguments{Stop: stop}) + }) + + Context("Where a Pod has been added", func() { + It("should be able to lookup the controller", func() { + Expect(mgr.GetController("TestInstance")).Should(Equal(instance)) + }) + + It("should be able to lookup the informer provider", func() { + Expect(mgr.GetInformerProvider(&corev1.Pod{})).Should(Equal(fakePodInformer)) + }) + + It("should be able to lookup the informer provider", func() { + Expect(mgr.GetInformer(&corev1.Pod{})).Should(Equal(fakePodInformer)) + }) + + It("should reconcile the Pod namespace/name", func() { + // Listen for Pod changes + Expect(instance.Watch(&corev1.Pod{})).Should(Succeed()) + + // Create a Pod event + fakePodInformer.Add(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "test-pod", Namespace: "default"}}) + + val := ChannelResult{} + Eventually(result).Should(Receive(&val.result)) + Expect(val.result).Should(Equal("default/test-pod")) + Expect(instance.GetMetrics().QueueLength).Should(Equal(0)) + }) + + It("should reconcile the Controller namespace/name if the UID matches", func() { + // Function to lookup the ReplicaSet based on the key + fn := func(k types.ReconcileKey) (interface{}, error) { + return &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-replicaset", + Namespace: "default", + UID: "uid5", + }, + }, nil + } + // Listen for Pod changes + Expect(instance.WatchControllerOf(&corev1.Pod{}, eventhandlers.Path{fn})).Should(Succeed()) + + fakePodInformer.Add(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + OwnerReferences: []metav1.OwnerReference{ + { + Name: "test-replicaset", + Controller: &t, + UID: "uid5", + }, + }, + }, + }) + + val := ChannelResult{} + Eventually(result).Should(Receive(&val.result)) + Expect(val.result).Should(Equal("default/test-replicaset")) + Expect(instance.GetMetrics().QueueLength).Should(Equal(0)) + }) + + It("should not reconcile the Controller namespace/name if the UID doesn't match", func() { + // Function to lookup the ReplicaSet based on the key + fn := func(k types.ReconcileKey) (interface{}, error) { + return &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-replicaset", + Namespace: "default", + UID: "uid5", + }, + }, nil + } + // Listen for Pod changes + Expect(instance.WatchControllerOf(&corev1.Pod{}, eventhandlers.Path{fn})).Should(Succeed()) + + fakePodInformer.Add(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + OwnerReferences: []metav1.OwnerReference{ + { + Name: "test-replicaset", + Controller: &t, + UID: "uid3", // UID doesn't match + }, + }, + }, + }) + + val := ChannelResult{} + Consistently(result).Should(Not(Receive(&val.result))) + }) + + It("should reconcile the Controller-Controller namespace/name", func() { + // Function to lookup the ReplicaSet based on the key + fn1 := func(k types.ReconcileKey) (interface{}, error) { + return &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-replicaset", + Namespace: "default", + UID: "uid5", + OwnerReferences: []metav1.OwnerReference{ + { + Name: "test-deployment", + UID: "uid7", + Controller: &t, + }, + }, + }, + }, nil + } + fn2 := func(k types.ReconcileKey) (interface{}, error) { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deployment", + Namespace: "default", + UID: "uid7", + }, + }, nil + } + + Expect(instance.WatchControllerOf(&corev1.Pod{}, eventhandlers.Path{fn1, fn2})).Should(Succeed()) + fakePodInformer.Add(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + OwnerReferences: []metav1.OwnerReference{ + { + Name: "test-replicaset", + Controller: &t, + UID: "uid5", + }, + }, + }, + }) + + val := ChannelResult{} + Eventually(result).Should(Receive(&val.result)) + Expect(val.result).Should(Equal("default/test-deployment")) + Expect(instance.GetMetrics().QueueLength).Should(Equal(0)) + }) + + It("should use the transformation function to reconcile a different key", func() { + // Listen for Pod changes + Expect(instance.WatchTransformationOf(&corev1.Pod{}, func(obj interface{}) string { + p := obj.(*corev1.Pod) + return p.Namespace + "-namespace/" + p.Name + "-name" + })).Should(Succeed()) + + fakePodInformer.Add(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "test-pod", Namespace: "default"}}) + + val := ChannelResult{} + Eventually(result).Should(Receive(&val.result)) + Expect(val.result).Should(Equal("default-namespace/test-pod-name")) + Expect(instance.GetMetrics().QueueLength).Should(Equal(0)) + }) + + It("should use the transformationkey function to reconcile a different key", func() { + // Listen for Pod changes + Expect(instance.WatchTransformationKeyOf(&corev1.Pod{}, func(obj interface{}) types.ReconcileKey { + p := obj.(*corev1.Pod) + return types.ReconcileKey{p.Namespace + "-namespace", p.Name + "-name"} + })).Should(Succeed()) + + fakePodInformer.Add(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "test-pod", Namespace: "default"}}) + + val := ChannelResult{} + Eventually(result).Should(Receive(&val.result)) + Expect(val.result).Should(Equal("default-namespace/test-pod-name")) + Expect(instance.GetMetrics().QueueLength).Should(Equal(0)) + }) + + It("should use the transformationsof function to reconcile multiple different keys", func() { + // Listen for Pod changes + Expect(instance.WatchTransformationsOf(&corev1.Pod{}, func(obj interface{}) []string { + p := obj.(*corev1.Pod) + return []string{ + p.Namespace + "-namespace/" + p.Name + "-name-1", + p.Namespace + "-namespace/" + p.Name + "-name-2"} + })).Should(Succeed()) + + fakePodInformer.Add(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "test-pod", Namespace: "default"}}) + + val := ChannelResult{} + Eventually(result).Should(Receive(&val.result)) + Expect(val.result).Should(Equal("default-namespace/test-pod-name-1")) + Eventually(result).Should(Receive(&val.result)) + Expect(val.result).Should(Equal("default-namespace/test-pod-name-2")) + Expect(instance.GetMetrics().QueueLength).Should(Equal(0)) + }) + + It("should use the transformationkeysof function to reconcile multiple different keys", func() { + // Listen for Pod changes + Expect(instance.WatchTransformationKeysOf(&corev1.Pod{}, func(obj interface{}) []types.ReconcileKey { + p := obj.(*corev1.Pod) + return []types.ReconcileKey{ + {p.Namespace + "-namespace", p.Name + "-name-1"}, + {p.Namespace + "-namespace", p.Name + "-name-2"}, + } + })).Should(Succeed()) + + fakePodInformer.Add(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "test-pod", Namespace: "default"}}) + + val := ChannelResult{} + Eventually(result).Should(Receive(&val.result)) + Expect(val.result).Should(Equal("default-namespace/test-pod-name-1")) + Eventually(result).Should(Receive(&val.result)) + Expect(val.result).Should(Equal("default-namespace/test-pod-name-2")) + Expect(instance.GetMetrics().QueueLength).Should(Equal(0)) + }) + + It("should call the event handling add function", func() { + // Listen for Pod changes + Expect(instance.WatchEvents(&corev1.Pod{}, + func(w workqueue.RateLimitingInterface) cache.ResourceEventHandler { + return cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { w.AddRateLimited("key/value") }, + DeleteFunc: func(obj interface{}) { Fail("Delete function called") }, + UpdateFunc: func(obj, old interface{}) { Fail("Update function called") }, + } + })).Should(Succeed()) + + fakePodInformer.Add(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "test-pod", Namespace: "default"}}) + + val := ChannelResult{} + Eventually(result).Should(Receive(&val.result)) + Expect(val.result).Should(Equal("key/value")) + Expect(instance.GetMetrics().QueueLength).Should(Equal(0)) + }) + }) + + Context("Where a Pod has been updated", func() { + It("should call the event handling update function", func() { + // Listen for Pod changes + Expect(instance.WatchEvents(&corev1.Pod{}, + func(w workqueue.RateLimitingInterface) cache.ResourceEventHandler { + return cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { Fail("Add function called") }, + DeleteFunc: func(obj interface{}) { Fail("Delete function called") }, + UpdateFunc: func(obj, old interface{}) { w.AddRateLimited("key/value") }, + } + })).Should(Succeed()) + + fakePodInformer.Update(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + }, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + }) + + val := ChannelResult{} + Eventually(result).Should(Receive(&val.result)) + Expect(val.result).Should(Equal("key/value")) + Expect(instance.GetMetrics().QueueLength).Should(Equal(0)) + }) + }) + + Context("Where a Pod has been deleted", func() { + It("should call the event handling delete function", func() { + // Listen for Pod changes + Expect(instance.WatchEvents(&corev1.Pod{}, + func(w workqueue.RateLimitingInterface) cache.ResourceEventHandler { + return cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { Fail("Add function called") }, + DeleteFunc: func(obj interface{}) { w.AddRateLimited("key/value") }, + UpdateFunc: func(obj, old interface{}) { Fail("Update function called") }, + } + })).Should(Succeed()) + + fakePodInformer.Delete(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "test-pod", Namespace: "default"}}) + + val := ChannelResult{} + Eventually(result).Should(Receive(&val.result)) + Expect(val.result).Should(Equal("key/value")) + Expect(instance.GetMetrics().QueueLength).Should(Equal(0)) + }) + }) + }) + + Describe("Watching a channel", func() { + BeforeEach(func() { + ch = make(chan string) + instance = &GenericController{ + Name: "TestInstance", + InformerRegistry: mgr, + Reconcile: func(k types.ReconcileKey) error { + // Write the result to a channel + result <- k.Namespace + "/" + k.Name + return nil + }, + } + mgr.AddController(instance) + Expect(instance.WatchChannel(ch)).Should(Succeed()) + mgr.RunInformersAndControllers(run.RunArguments{Stop: stop}) + }) + + Context("Where a key is added to the channel", func() { + It("should reconcile the added namespace/name", func() { + go func() { ch <- "hello/world" }() + val := ChannelResult{} + Eventually(result, time.Second*1).Should(Receive(&val.result)) + Expect(val.result).Should(Equal("hello/world")) + Expect(instance.GetMetrics().QueueLength).Should(Equal(0)) + }) + }) + + Context("Where a key does not have a namespace/name", func() { + It("should not reconcile the any namespace/name", func() { + go func() { ch <- "hello/world/foo" }() + val := ChannelResult{} + Consistently(result, time.Second*1).Should(Not(Receive(&val.result))) + }) + }) + }) + + Describe("Creating an empty controller", func() { + BeforeEach(func() { + instance = &GenericController{ + AfterReconcile: func(k types.ReconcileKey, err error) { + defer GinkgoRecover() + Expect(err).Should(BeNil()) + result <- k.Namespace + "/" + k.Name + }, + } + defaultManager = ControllerManager{} + AddInformerProvider(&corev1.Pod{}, fakePodInformer) + Expect(GetInformerProvider(&corev1.Pod{})).Should(Equal(fakePodInformer)) + AddController(instance) + RunInformersAndControllers(run.RunArguments{Stop: stop}) + }) + + It("should create a name for the controller", func() { + Expect(instance.Watch(&corev1.Pod{})).Should(Succeed()) + Expect(instance.Name).Should(Not(BeEmpty())) + }) + + It("should use the default informer registry", func() { + Expect(instance.Watch(&corev1.Pod{})).Should(Succeed()) + + // Create a Pod event + fakePodInformer.Add(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "test-pod", Namespace: "default"}}) + + val := ChannelResult{} + Eventually(result).Should(Receive(&val.result)) + Expect(val.result).Should(Equal("default/test-pod")) + Expect(instance.GetMetrics().QueueLength).Should(Equal(0)) + }) + }) + + Describe("Adding a non-string item to the queue", func() { + BeforeEach(func() { + instance = &GenericController{ + Name: "TestInstance", + InformerRegistry: mgr, + Reconcile: func(k types.ReconcileKey) error { + // Write the result to a channel + result <- k.Namespace + "/" + k.Name + return nil + }, + } + mgr.AddController(instance) + mgr.RunInformersAndControllers(run.RunArguments{Stop: stop}) + }) + It("should not call reconcile", func() { + instance.Watch(&corev1.Pod{}) + instance.queue.AddRateLimited(fakePodInformer) + val := ChannelResult{} + Consistently(result).Should(Not(Receive(&val.result))) + }) + }) + + Describe("Adding string where the namespace/name cannot be parsed", func() { + BeforeEach(func() { + instance = &GenericController{ + Name: "TestInstance", + InformerRegistry: mgr, + Reconcile: func(k types.ReconcileKey) error { + // Write the result to a channel + result <- k.Namespace + "/" + k.Name + return nil + }, + } + mgr.AddController(instance) + mgr.RunInformersAndControllers(run.RunArguments{Stop: stop}) + }) + + It("should not call reconcile", func() { + instance.Watch(&corev1.Pod{}) + instance.queue.AddRateLimited("1/2/3") + val := ChannelResult{} + Consistently(result).Should(Not(Receive(&val.result))) + }) + }) + + Describe("Re-queue an item when reconcile returns error", func() { + BeforeEach(func() { + var counter uint64 + instance = &GenericController{ + Name: "TestInstance", + InformerRegistry: mgr, + Reconcile: func(k types.ReconcileKey) error { + result <- fmt.Sprintf("retry-%d", counter) + atomic.AddUint64(&counter, 1) + return fmt.Errorf("error") + }, + } + mgr.AddController(instance) + mgr.RunInformersAndControllers(run.RunArguments{Stop: stop}) + }) + + It("should add the item back to the queue", func() { + instance.Watch(&corev1.Pod{}) + // Create a Pod event + fakePodInformer.Add(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "failed-pod", Namespace: "default"}}) + val := ChannelResult{} + Eventually(result).Should(Receive(&val.result)) + Expect(val.result).Should(Equal("retry-0")) + Eventually(result).Should(Receive(&val.result)) + Expect(val.result).Should(Equal("retry-1")) + }) + }) + + AfterEach(func() { + close(stop) + }) +}) + +type ChannelResult struct { + result string +} diff --git a/pkg/controller/doc.go b/pkg/controller/doc.go new file mode 100644 index 0000000000..9ab1352e85 --- /dev/null +++ b/pkg/controller/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// The controller package provides libraries for creating controllers. +package controller diff --git a/pkg/controller/eventhandlers/doc.go b/pkg/controller/eventhandlers/doc.go new file mode 100644 index 0000000000..809e18e8de --- /dev/null +++ b/pkg/controller/eventhandlers/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// The handlefunctions defines mapping and event handling functions for controllers +package eventhandlers diff --git a/pkg/controller/eventhandlers/eventhandlers.go b/pkg/controller/eventhandlers/eventhandlers.go new file mode 100644 index 0000000000..4716dcfad6 --- /dev/null +++ b/pkg/controller/eventhandlers/eventhandlers.go @@ -0,0 +1,178 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package eventhandlers + +import ( + "fmt" + + "github.com/golang/glog" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/predicates" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/types" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" +) + +// EventHandler accepts a workqueue and returns ResourceEventHandler that enqueue messages to it +// for add / update / delete events +type EventHandler interface { + Get(r workqueue.RateLimitingInterface) cache.ResourceEventHandler +} + +// MapAndEnqueue provides Fns to map objects to name/namespace keys and enqueue them as messages +type MapAndEnqueue struct { + Predicates []predicates.Predicate + // Map maps an object to a key that can be enqueued + Map func(interface{}) string + + MultiMap func(interface{}) []types.ReconcileKey +} + +// Get returns ResourceEventHandler that Map an object to a Key and enqueue the key if it is non-empty +func (mp MapAndEnqueue) Get(r workqueue.RateLimitingInterface) cache.ResourceEventHandler { + // Enqueue the mapped key for updates to the object + return cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + for _, p := range mp.Predicates { + if !p.HandleCreate(obj) { + return + } + } + mp.addRateLimited(r, obj) + }, + UpdateFunc: func(old, obj interface{}) { + for _, p := range mp.Predicates { + if !p.HandleUpdate(old, obj) { + return + } + } + mp.addRateLimited(r, obj) + }, + DeleteFunc: func(obj interface{}) { + for _, p := range mp.Predicates { + if !p.HandleDelete(obj) { + return + } + } + mp.addRateLimited(r, obj) + }, + } +} + +// addRateLimited maps the obj to a string. If the string is non-empty, it is enqueued. +func (mp MapAndEnqueue) addRateLimited(r workqueue.RateLimitingInterface, obj interface{}) { + if mp.Map != nil { + if k := mp.Map(obj); len(k) > 0 { + r.AddRateLimited(k) + } + } + if mp.MultiMap != nil { + for _, k := range mp.MultiMap(obj) { + r.AddRateLimited(k.Namespace + "/" + k.Name) + } + } +} + +// ControllerLookup takes a ReconcileKey and returns the matching resource +type ControllerLookup func(types.ReconcileKey) (interface{}, error) + +// Path is list of functions that allow an instance of a resource to be traced back to an owning ancestor. This +// is done by following the chain of owners references and comparing the UID in the owners reference against +// the UID of the instance returned by ControllerLookup. +// e.g. if resource Foo creates Deployments, and wanted to trigger reconciles in response to Pod events created by +// the Deployment, then Path would contain the following [function to lookup a ReplicaSet by namespace/name, +// function to lookup a Deployment by namespace/name, function to lookup a Foo by namespace/name]. When +// a Pod event is observed, this Path could then walk the owners references back to the Foo to get its namespace/name +// and then reconcile this Foo. +type Path []ControllerLookup + +type MapToController struct { + Path Path +} + +// MapToController returns the namespace/name key of the controller for obj +func (m MapToController) Map(obj interface{}) string { + var object metav1.Object + var ok bool + if object, ok = obj.(metav1.Object); !ok { + tombstone, ok := obj.(cache.DeletedFinalStateUnknown) + if !ok { + runtime.HandleError(fmt.Errorf("error decoding object, invalid type")) + return "" + } + object, ok = tombstone.Obj.(metav1.Object) + if !ok { + runtime.HandleError(fmt.Errorf("error decoding object tombstone, invalid type")) + return "" + } + glog.V(4).Infof("Recovered deleted object '%s' from tombstone", object.GetName()) + } + glog.V(4).Infof("Processing object: %s", object.GetName()) + // Walk the controller path to the root + o := object + for len(m.Path) > 0 { + // Get the owner reference + ownerRef := metav1.GetControllerOf(o) + if ownerRef == nil { + glog.V(2).Infof("object %v does not have any owner reference", o) + return "" + } + // Resolve the owner object and check if the UID of the looked up object matches the reference. + owner, err := m.Path[0](types.ReconcileKey{Name: ownerRef.Name, Namespace: o.GetNamespace()}) + if err != nil || owner == nil { + glog.V(2).Infof("Could not lookup owner %v %v", owner, err) + return "" + } + var ownerObject metav1.Object + if ownerObject, ok = owner.(metav1.Object); !ok { + glog.V(2).Infof("No ObjectMeta for owner %v %v", owner, err) + return "" + } + if ownerObject.GetUID() != ownerRef.UID { + return "" + } + + // Pop the path element or return the value + if len(m.Path) > 1 { + o = ownerObject + m.Path = m.Path[1:] + } else { + return object.GetNamespace() + "/" + ownerRef.Name + } + } + return "" +} + +// ObjToKey returns a string namespace/name key for an object +type ObjToKey func(interface{}) string + +type ObjToKeys func(interface{}) []string + +type ObjToReconcileKey func(interface{}) types.ReconcileKey + +type ObjToReconcileKeys func(interface{}) []types.ReconcileKey + +// MapToSelf returns the namespace/name key of obj +func MapToSelf(obj interface{}) string { + if key, err := cache.MetaNamespaceKeyFunc(obj); err != nil { + runtime.HandleError(err) + return "" + } else { + return key + } +} diff --git a/pkg/controller/eventhandlers/eventhandlers_suite_test.go b/pkg/controller/eventhandlers/eventhandlers_suite_test.go new file mode 100644 index 0000000000..8da28f7c58 --- /dev/null +++ b/pkg/controller/eventhandlers/eventhandlers_suite_test.go @@ -0,0 +1,29 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package eventhandlers + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "testing" +) + +func TestEventhandlers(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Eventhandlers Suite") +} diff --git a/pkg/controller/eventhandlers/eventhandlers_test.go b/pkg/controller/eventhandlers/eventhandlers_test.go new file mode 100644 index 0000000000..debd83b788 --- /dev/null +++ b/pkg/controller/eventhandlers/eventhandlers_test.go @@ -0,0 +1,419 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package eventhandlers_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/eventhandlers" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/types" + + "fmt" + //"github.com/kubernetes-sigs/kubebuilder/pkg/controller/predicates" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/predicates" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/workqueue" +) + +var _ = Describe("Eventhandlers", func() { + var ( + t = true + mae = eventhandlers.MapAndEnqueue{} + q = workqueue.NewNamedRateLimitingQueue(workqueue.NewMaxOfRateLimiter(), "world") + ) + + BeforeEach(func() { + mae = eventhandlers.MapAndEnqueue{ + Map: func(i interface{}) string { return fmt.Sprintf("p-%v", i) }, + } + q = workqueue.NewNamedRateLimitingQueue(workqueue.NewMaxOfRateLimiter( + workqueue.NewItemExponentialFailureRateLimiter(0, 0), + ), "world") + }) + + Describe("When mapping and enqueuing an event", func() { + Context("Where there are no Predicates", func() { + It("should set the Add function", func() { + fns := mae.Get(q) + fns.OnAdd("add") + Eventually(q.Len).Should(Equal(1)) + Expect(q.Get()).Should(Equal("p-add")) + }) + + It("should set the Delete function", func() { + fns := mae.Get(q) + fns.OnDelete("delete") + Eventually(q.Len()).Should(Equal(1)) + Expect(q.Get()).Should(Equal("p-delete")) + }) + + It("should set the Update function", func() { + fns := mae.Get(q) + fns.OnUpdate("old", "update") + Eventually(q.Len()).Should(Equal(1)) + Expect(q.Get()).Should(Equal("p-update")) + }) + }) + + Context("Where there is one true Predicate", func() { + It("should set the Add function", func() { + mae.Predicates = []predicates.Predicate{FakePredicates{create: true}} + fns := mae.Get(q) + fns.OnAdd("add") + Eventually(q.Len()).Should(Equal(1)) + Expect(q.Get()).Should(Equal("p-add")) + + fns.OnDelete("delete") + fns.OnUpdate("old", "update") + Consistently(q.Len).Should(Equal(0)) + }) + + It("should set the Delete function", func() { + mae.Predicates = []predicates.Predicate{FakePredicates{delete: true}} + fns := mae.Get(q) + fns.OnDelete("delete") + Eventually(q.Len()).Should(Equal(1)) + Expect(q.Get()).Should(Equal("p-delete")) + + fns.OnAdd("add") + fns.OnUpdate("old", "add") + Consistently(q.Len).Should(Equal(0)) + }) + + It("should set the Update function", func() { + mae.Predicates = []predicates.Predicate{FakePredicates{update: true}} + fns := mae.Get(q) + fns.OnUpdate("old", "update") + Eventually(q.Len()).Should(Equal(1)) + Expect(q.Get()).Should(Equal("p-update")) + + fns.OnAdd("add") + fns.OnDelete("delete") + Consistently(q.Len).Should(Equal(0)) + }) + }) + + Context("Where there are both true and false Predicates", func() { + Context("Where there is one false Predicate", func() { + It("should not Add", func() { + mae.Predicates = []predicates.Predicate{FakePredicates{create: true}, FakePredicates{}} + fns := mae.Get(q) + fns.OnAdd("add") + Consistently(q.Len).Should(Equal(0)) + }) + + It("should not Delete", func() { + mae.Predicates = []predicates.Predicate{FakePredicates{delete: true}, FakePredicates{}} + fns := mae.Get(q) + fns.OnDelete("delete") + Consistently(q.Len).Should(Equal(0)) + }) + + It("should not Update", func() { + mae.Predicates = []predicates.Predicate{FakePredicates{update: true}, FakePredicates{}} + fns := mae.Get(q) + fns.OnUpdate("old", "update") + Consistently(q.Len).Should(Equal(0)) + }) + + It("should not Add", func() { + mae.Predicates = []predicates.Predicate{FakePredicates{}, FakePredicates{create: true}} + fns := mae.Get(q) + fns.OnAdd("add") + Consistently(q.Len).Should(Equal(0)) + }) + + It("should not Delete", func() { + mae.Predicates = []predicates.Predicate{FakePredicates{}, FakePredicates{delete: true}} + fns := mae.Get(q) + fns.OnDelete("delete") + Consistently(q.Len).Should(Equal(0)) + }) + + It("should not Update", func() { + mae.Predicates = []predicates.Predicate{FakePredicates{}, FakePredicates{update: true}} + fns := mae.Get(q) + fns.OnUpdate("old", "update") + Consistently(q.Len).Should(Equal(0)) + }) + }) + }) + }) + + Describe("When mapping an object to itself", func() { + Context("Where the object has key metadata", func() { + It("should return the reconcile key for itself", func() { + result := eventhandlers.MapToSelf(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "not-default", + OwnerReferences: []metav1.OwnerReference{ + { + Name: "test-replicaset", + Controller: &t, + UID: "uid5", + }, + }, + }, + }) + Expect(result).Should(Equal("not-default/test-pod")) + }) + }) + + Context("Where the object does not have key metadata", func() { + It("should return the empty string", func() { + obj := "" + result := eventhandlers.MapToSelf(&obj) + Expect(result).Should(Equal("")) + }) + }) + }) + + Describe("When mapping events for an object to the objects controller", func() { + var ( + mtc = eventhandlers.MapToController{} + ) + BeforeEach(func() { + mtc = eventhandlers.MapToController{} + }) + + Context("Where the object doesn't have metadata", func() { + It("should return the empty string", func() { + s := "" + result := mtc.Map(&s) + Expect(result).Should(Equal("")) + }) + }) + + Context("Where the path is empty", func() { + It("should return the empty string", func() { + result := mtc.Map(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + OwnerReferences: []metav1.OwnerReference{ + { + Name: "test-replicaset", + Controller: &t, + UID: "uid5", + }, + }, + }, + }) + Expect(result).Should(Equal("")) + }) + }) + + Context("Where the controller isn't found", func() { + It("should return the empty string", func() { + mtc.Path = eventhandlers.Path{ + func(k types.ReconcileKey) (interface{}, error) { + return nil, nil + }, + } + result := mtc.Map(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + OwnerReferences: []metav1.OwnerReference{ + { + Name: "test-replicaset", + Controller: &t, + UID: "uid5", + }, + }, + }, + }) + Expect(result).Should(Equal("")) + }) + }) + + Context("Where an error is returned when looking up the controller", func() { + It("should return the empty string", func() { + mtc.Path = eventhandlers.Path{ + func(k types.ReconcileKey) (interface{}, error) { + return &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-replicaset", + Namespace: "default", + UID: "uid5", + }, + }, fmt.Errorf("error") + }, + } + result := mtc.Map(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + OwnerReferences: []metav1.OwnerReference{ + { + Name: "test-replicaset", + Controller: &t, + UID: "uid5", + }, + }, + }, + }) + Expect(result).Should(Equal("")) + }) + }) + + Context("Where the returned controller doesn't have metadata", func() { + It("should return the empty string", func() { + mtc.Path = eventhandlers.Path{ + func(k types.ReconcileKey) (interface{}, error) { + s := "" + return &s, nil + }, + } + result := mtc.Map(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + OwnerReferences: []metav1.OwnerReference{ + { + Name: "test-replicaset", + Controller: &t, + UID: "uid5", + }, + }, + }, + }) + Expect(result).Should(Equal("")) + }) + }) + + Context("Where the controller UID matches", func() { + It("should return the controller's namespace/name", func() { + mtc.Path = eventhandlers.Path{ + func(k types.ReconcileKey) (interface{}, error) { + return &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-replicaset", + Namespace: "default", + UID: "uid5", + }, + }, nil + }, + } + result := mtc.Map(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + OwnerReferences: []metav1.OwnerReference{ + { + Name: "test-replicaset", + Controller: &t, + UID: "uid5", + }, + }, + }, + }) + Expect(result).Should(Equal("default/test-replicaset")) + }) + }) + + Context("Where the controller UID doesn't match", func() { + It("should not return the controller's namespace/name", func() { + mtc.Path = eventhandlers.Path{ + func(k types.ReconcileKey) (interface{}, error) { + defer GinkgoRecover() + Expect(k).Should(Equal(types.ReconcileKey{Name: "test-replicaset", Namespace: "default"})) + return &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-replicaset", + Namespace: "default", + UID: "uid5", + }, + }, nil + }, + } + result := mtc.Map(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + OwnerReferences: []metav1.OwnerReference{ + { + Name: "test-replicaset", + Controller: &t, + UID: "uid3", + }, + }, + }, + }) + Expect(result).Should(Equal("")) + }) + }) + + Context("Where the controller maps to another controller", func() { + It("should return the controller's-controller's namespace/name", func() { + mtc.Path = eventhandlers.Path{ + func(k types.ReconcileKey) (interface{}, error) { + return &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-replicaset", + Namespace: "default", + UID: "uid5", + OwnerReferences: []metav1.OwnerReference{ + { + Name: "test-deployment", + UID: "uid7", + Controller: &t, + }, + }, + }, + }, nil + }, + func(k types.ReconcileKey) (interface{}, error) { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deployment", + Namespace: "default", + UID: "uid7", + }, + }, nil + }, + } + result := mtc.Map(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + OwnerReferences: []metav1.OwnerReference{ + { + Name: "test-replicaset", + Controller: &t, + UID: "uid5", + }, + }, + }, + }) + Expect(result).Should(Equal("default/test-deployment")) + }) + }) + }) +}) + +type FakePredicates struct { + update, delete, create bool +} + +func (h FakePredicates) HandleUpdate(old, new interface{}) bool { return h.update } +func (h FakePredicates) HandleDelete(obj interface{}) bool { return h.delete } +func (h FakePredicates) HandleCreate(obj interface{}) bool { return h.create } diff --git a/pkg/controller/example_addcontroller_test.go b/pkg/controller/example_addcontroller_test.go new file mode 100644 index 0000000000..b76b267cfb --- /dev/null +++ b/pkg/controller/example_addcontroller_test.go @@ -0,0 +1,38 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller_test + +import ( + "log" + + "github.com/kubernetes-sigs/kubebuilder/pkg/controller" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/types" + "k8s.io/api/core/v1" +) + +func ExampleAddController() { + podController := &controller.GenericController{ + Reconcile: func(key types.ReconcileKey) error { + log.Printf("Reconciling Pod %v\n", key) + return nil + }, + } + if err := podController.Watch(&v1.Pod{}); err != nil { + log.Fatalf("%v", err) + } + controller.AddController(podController) +} diff --git a/pkg/controller/example_addinformerprovider_test.go b/pkg/controller/example_addinformerprovider_test.go new file mode 100644 index 0000000000..2563be26df --- /dev/null +++ b/pkg/controller/example_addinformerprovider_test.go @@ -0,0 +1,36 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller_test + +import ( + "flag" + "log" + + "github.com/kubernetes-sigs/kubebuilder/pkg/config" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller" + corev1 "k8s.io/api/core/v1" +) + +func ExampleAddInformerProvider() { + flag.Parse() + informerFactory := config.GetKubernetesInformersOrDie() + + // Register informers to Watch for Pod events + if err := controller.AddInformerProvider(&corev1.Pod{}, informerFactory.Core().V1().Pods()); err != nil { + log.Fatalf("Could not set informer %v", err) + } +} diff --git a/pkg/controller/example_controller_test.go b/pkg/controller/example_controller_test.go new file mode 100644 index 0000000000..a48eeb3c16 --- /dev/null +++ b/pkg/controller/example_controller_test.go @@ -0,0 +1,77 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller_test + +import ( + "flag" + "fmt" + "log" + + "github.com/kubernetes-sigs/kubebuilder/pkg/config" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/eventhandlers" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/types" + "github.com/kubernetes-sigs/kubebuilder/pkg/inject/run" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" +) + +func ExampleGenericController() { + // Step 1: Register informers with the ControllerManager to Watch for Pod and ReplicaSet events + flag.Parse() + informerFactory := config.GetKubernetesInformersOrDie() + if err := controller.AddInformerProvider(&corev1.Pod{}, informerFactory.Core().V1().Pods()); err != nil { + log.Fatalf("Could not set informer %v", err) + } + if err := controller.AddInformerProvider(&appsv1.ReplicaSet{}, informerFactory.Apps().V1().ReplicaSets()); err != nil { + log.Fatalf("Could not set informer %v", err) + } + + // Step 2.1: Create a new Pod controller to reconcile Pods changes + podController := &controller.GenericController{ + Reconcile: func(key types.ReconcileKey) error { + fmt.Printf("Reconciling Pod %v\n", key) + return nil + }, + } + if err := podController.Watch(&corev1.Pod{}); err != nil { + log.Fatalf("%v", err) + } + controller.AddController(podController) + + // Step 2.2: Create a new ReplicaSet controller to reconcile ReplicaSet changes + rsController := &controller.GenericController{ + Reconcile: func(key types.ReconcileKey) error { + fmt.Printf("Reconciling ReplicaSet %v\n", key) + return nil + }, + } + + fn := func(k types.ReconcileKey) (interface{}, error) { + return informerFactory.Apps().V1().ReplicaSets().Lister().ReplicaSets(k.Namespace).Get(k.Name) + } + if err := rsController.WatchControllerOf(&corev1.Pod{}, eventhandlers.Path{fn}); err != nil { + log.Fatalf("%v", err) + } + if err := rsController.Watch(&appsv1.ReplicaSet{}); err != nil { + log.Fatalf("%v", err) + } + controller.AddController(rsController) + + // Step 3: RunInformersAndControllers all informers and controllers + controller.RunInformersAndControllers(run.CreateRunArguments()) +} diff --git a/pkg/controller/example_controllermanager_test.go b/pkg/controller/example_controllermanager_test.go new file mode 100644 index 0000000000..7eedd77dff --- /dev/null +++ b/pkg/controller/example_controllermanager_test.go @@ -0,0 +1,24 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller_test + +import "github.com/kubernetes-sigs/kubebuilder/pkg/controller" + +func ExampleControllerManager() { + // Create a new empty ControllerManager for managing Informers and Controllers + var _ = &controller.ControllerManager{} +} diff --git a/pkg/controller/example_getinformerprovider_test.go b/pkg/controller/example_getinformerprovider_test.go new file mode 100644 index 0000000000..04f408e0bb --- /dev/null +++ b/pkg/controller/example_getinformerprovider_test.go @@ -0,0 +1,38 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller_test + +import ( + "flag" + "log" + + "github.com/kubernetes-sigs/kubebuilder/pkg/controller" + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/informers/core/v1" +) + +func ExampleGetInformerProvider() { + // Get the registered PodInformer + flag.Parse() + provider := controller.GetInformerProvider(&corev1.Pod{}) + podinformer, ok := provider.(v1.PodInformer) + if !ok { + log.Fatalf("Expected PodInformer for Pod, got %T", podinformer) + } + lister := podinformer.Lister() + lister.Pods("default").Get("pod-name") +} diff --git a/pkg/controller/example_runinformersandcontrollers_test.go b/pkg/controller/example_runinformersandcontrollers_test.go new file mode 100644 index 0000000000..23fef39d7b --- /dev/null +++ b/pkg/controller/example_runinformersandcontrollers_test.go @@ -0,0 +1,27 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller_test + +import ( + "github.com/kubernetes-sigs/kubebuilder/pkg/controller" + "github.com/kubernetes-sigs/kubebuilder/pkg/inject/run" +) + +func ExampleRunInformersAndControllers() { + // RunInformersAndControllers all registered informers and controllers + controller.RunInformersAndControllers(run.CreateRunArguments()) +} diff --git a/pkg/controller/example_second_test.go b/pkg/controller/example_second_test.go new file mode 100644 index 0000000000..343b1b9108 --- /dev/null +++ b/pkg/controller/example_second_test.go @@ -0,0 +1,70 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller_test + +import ( + "flag" + "log" + + "github.com/kubernetes-sigs/kubebuilder/pkg/config" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/types" + "github.com/kubernetes-sigs/kubebuilder/pkg/inject/run" + corev1 "k8s.io/api/core/v1" + corev1informer "k8s.io/client-go/informers/core/v1" + corev1lister "k8s.io/client-go/listers/core/v1" +) + +func Example_second() { + // Step 1: Register informers to Watch for Pod events + flag.Parse() + informerFactory := config.GetKubernetesInformersOrDie() + if err := controller.AddInformerProvider(&corev1.Pod{}, informerFactory.Core().V1().Pods()); err != nil { + log.Fatalf("Could not set informer %v", err) + } + + // Step 2: Create a new Pod controller to reconcile Pods changes using the default + // reconcile function to print messages on events + podController := &Controller{ + podlister: controller.GetInformerProvider(&corev1.Pod{}).(corev1informer.PodInformer).Lister(), + } + genericController := &controller.GenericController{ + Name: "PodController", + Reconcile: podController.Reconcile, + } + if err := genericController.Watch(&corev1.Pod{}); err != nil { + log.Fatalf("%v", err) + } + controller.AddController(genericController) + + // Step 3: RunInformersAndControllers all informers and controllers + controller.RunInformersAndControllers(run.CreateRunArguments()) +} + +type Controller struct { + podlister corev1lister.PodLister +} + +func (c *Controller) Reconcile(key types.ReconcileKey) error { + pod, err := c.podlister.Pods(key.Namespace).Get(key.Name) + if err != nil { + log.Printf("Failed to reconcile Pod %+v\n", err) + return err + } + log.Printf("reconcile Pod %+v\n", pod) + return nil +} diff --git a/pkg/controller/example_test.go b/pkg/controller/example_test.go new file mode 100644 index 0000000000..65f4cf6fd7 --- /dev/null +++ b/pkg/controller/example_test.go @@ -0,0 +1,47 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller_test + +import ( + "flag" + "log" + + "github.com/kubernetes-sigs/kubebuilder/pkg/config" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller" + "github.com/kubernetes-sigs/kubebuilder/pkg/inject/run" + corev1 "k8s.io/api/core/v1" +) + +func Example() { + // Step 1: Register informers to Watch for Pod events + flag.Parse() + informerFactory := config.GetKubernetesInformersOrDie() + if err := controller.AddInformerProvider(&corev1.Pod{}, informerFactory.Core().V1().Pods()); err != nil { + log.Fatalf("Could not set informer %v", err) + } + + // Step 2: Create a new Pod controller to reconcile Pods changes using the default + // reconcile function to print messages on events + podController := &controller.GenericController{} + if err := podController.Watch(&corev1.Pod{}); err != nil { + log.Fatalf("%v", err) + } + controller.AddController(podController) + + // Step 3: RunInformersAndControllers all informers and controllers + controller.RunInformersAndControllers(run.CreateRunArguments()) +} diff --git a/pkg/controller/example_watch_test.go b/pkg/controller/example_watch_test.go new file mode 100644 index 0000000000..9741719896 --- /dev/null +++ b/pkg/controller/example_watch_test.go @@ -0,0 +1,54 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller_test + +import ( + "flag" + "fmt" + "log" + + "github.com/kubernetes-sigs/kubebuilder/pkg/config" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/types" + "github.com/kubernetes-sigs/kubebuilder/pkg/inject/run" + "k8s.io/api/core/v1" +) + +func ExampleGenericController_Watch() { + // One time setup for program + flag.Parse() + informerFactory := config.GetKubernetesInformersOrDie() + if err := controller.AddInformerProvider(&v1.Pod{}, informerFactory.Core().V1().Pods()); err != nil { + log.Fatalf("Could not set informer %v", err) + } + + // Per-controller setup + c := &controller.GenericController{ + Name: "Foo", + Reconcile: func(key types.ReconcileKey) error { + fmt.Printf("Reconciling Pod %s\n", key) + return nil + }, + } + if err := c.Watch(&v1.Pod{}); err != nil { + log.Fatalf("%v", err) + } + controller.AddController(c) + + // One time for program + controller.RunInformersAndControllers(run.CreateRunArguments()) +} diff --git a/pkg/controller/example_watchandhandleevents_test.go b/pkg/controller/example_watchandhandleevents_test.go new file mode 100644 index 0000000000..b74071f1e0 --- /dev/null +++ b/pkg/controller/example_watchandhandleevents_test.go @@ -0,0 +1,67 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller_test + +import ( + "flag" + "fmt" + "log" + + "github.com/kubernetes-sigs/kubebuilder/pkg/config" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/eventhandlers" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/types" + "github.com/kubernetes-sigs/kubebuilder/pkg/inject/run" + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" +) + +func ExampleGenericController_WatchEvents() { + // One time setup for program + flag.Parse() + informerFactory := config.GetKubernetesInformersOrDie() + if err := controller.AddInformerProvider(&corev1.Pod{}, informerFactory.Core().V1().Pods()); err != nil { + log.Fatalf("Could not set informer %v", err) + + } + + // Per-controller setup + c := &controller.GenericController{ + Reconcile: func(key types.ReconcileKey) error { + fmt.Printf("Reconciling Pod %s\n", key) + return nil + }, + } + err := c.WatchEvents(&corev1.Pod{}, + // This function returns the callbacks that will be invoked for events + func(q workqueue.RateLimitingInterface) cache.ResourceEventHandler { + // This function implements the same functionality as GenericController.Watch + return cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { q.AddRateLimited(eventhandlers.MapToSelf(obj)) }, + UpdateFunc: func(old, obj interface{}) { q.AddRateLimited(eventhandlers.MapToSelf(obj)) }, + DeleteFunc: func(obj interface{}) { q.AddRateLimited(eventhandlers.MapToSelf(obj)) }, + } + }) + if err != nil { + log.Fatalf("%v", err) + } + controller.AddController(c) + + // One time for program + controller.RunInformersAndControllers(run.CreateRunArguments()) +} diff --git a/pkg/controller/example_watchandmap_test.go b/pkg/controller/example_watchandmap_test.go new file mode 100644 index 0000000000..f8de998413 --- /dev/null +++ b/pkg/controller/example_watchandmap_test.go @@ -0,0 +1,205 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller_test + +import ( + "flag" + "fmt" + "log" + "strings" + + "github.com/kubernetes-sigs/kubebuilder/pkg/config" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/types" + "github.com/kubernetes-sigs/kubebuilder/pkg/inject/run" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" +) + +func ExampleGenericController_WatchTransformationOf() { + // One time setup for program + flag.Parse() + informerFactory := config.GetKubernetesInformersOrDie() + if err := controller.AddInformerProvider(&corev1.Pod{}, informerFactory.Core().V1().Pods()); err != nil { + log.Fatalf("Could not set informer %v", err) + } + if err := controller.AddInformerProvider(&appsv1.ReplicaSet{}, informerFactory.Apps().V1().ReplicaSets()); err != nil { + log.Fatalf("Could not set informer %v", err) + } + + // Per-controller setup + c := &controller.GenericController{ + Reconcile: func(key types.ReconcileKey) error { + fmt.Printf("Reconciling Pod %s\n", key) + return nil + }, + } + err := c.Watch(&appsv1.ReplicaSet{}) + if err != nil { + log.Fatalf("%v", err) + } + err = c.WatchTransformationOf(&corev1.Pod{}, + func(i interface{}) string { + p, ok := i.(*corev1.Pod) + if !ok { + return "" + } + + // Find the parent key based on the name + return p.Namespace + "/" + strings.Split(p.Name, "-")[0] + }, + ) + if err != nil { + log.Fatalf("%v", err) + } + controller.AddController(c) + + // One time for program + controller.RunInformersAndControllers(run.CreateRunArguments()) +} + +func ExampleGenericController_WatchTransformationsOf() { + // One time setup for program + flag.Parse() + informerFactory := config.GetKubernetesInformersOrDie() + if err := controller.AddInformerProvider(&corev1.Pod{}, informerFactory.Core().V1().Pods()); err != nil { + log.Fatalf("Could not set informer %v", err) + } + if err := controller.AddInformerProvider(&appsv1.ReplicaSet{}, informerFactory.Apps().V1().ReplicaSets()); err != nil { + log.Fatalf("Could not set informer %v", err) + } + + // Per-controller setup + c := &controller.GenericController{ + Reconcile: func(key types.ReconcileKey) error { + fmt.Printf("Reconciling Pod %s\n", key) + return nil + }, + } + err := c.Watch(&appsv1.ReplicaSet{}) + if err != nil { + log.Fatalf("%v", err) + } + err = c.WatchTransformationsOf(&corev1.Pod{}, + func(i interface{}) []string { + p, ok := i.(*corev1.Pod) + if !ok { + return []string{} + } + + // Find multiple parents based off the name + return []string{ + p.Namespace + "/" + strings.Split(p.Name, "-")[0] + "-parent-1", + p.Namespace + "/" + strings.Split(p.Name, "-")[0] + "-parent-2", + } + }, + ) + if err != nil { + log.Fatalf("%v", err) + } + controller.AddController(c) + + // One time for program + controller.RunInformersAndControllers(run.CreateRunArguments()) +} + +func ExampleGenericController_WatchTransformationKeyOf() { + // One time setup for program + flag.Parse() + informerFactory := config.GetKubernetesInformersOrDie() + if err := controller.AddInformerProvider(&corev1.Pod{}, informerFactory.Core().V1().Pods()); err != nil { + log.Fatalf("Could not set informer %v", err) + } + if err := controller.AddInformerProvider(&appsv1.ReplicaSet{}, informerFactory.Apps().V1().ReplicaSets()); err != nil { + log.Fatalf("Could not set informer %v", err) + } + + // Per-controller setup + c := &controller.GenericController{ + Reconcile: func(key types.ReconcileKey) error { + fmt.Printf("Reconciling Pod %s\n", key) + return nil + }, + } + err := c.Watch(&appsv1.ReplicaSet{}) + if err != nil { + log.Fatalf("%v", err) + } + err = c.WatchTransformationKeyOf(&corev1.Pod{}, + func(i interface{}) types.ReconcileKey { + p, ok := i.(*corev1.Pod) + if !ok { + return types.ReconcileKey{} + } + + // Find multiple parents based off the name + return types.ReconcileKey{p.Namespace, strings.Split(p.Name, "-")[0]} + }, + ) + if err != nil { + log.Fatalf("%v", err) + } + controller.AddController(c) + + // One time for program + controller.RunInformersAndControllers(run.CreateRunArguments()) +} + +func ExampleGenericController_WatchTransformationKeysOf() { + // One time setup for program + flag.Parse() + informerFactory := config.GetKubernetesInformersOrDie() + if err := controller.AddInformerProvider(&corev1.Pod{}, informerFactory.Core().V1().Pods()); err != nil { + log.Fatalf("Could not set informer %v", err) + } + if err := controller.AddInformerProvider(&appsv1.ReplicaSet{}, informerFactory.Apps().V1().ReplicaSets()); err != nil { + log.Fatalf("Could not set informer %v", err) + } + + // Per-controller setup + c := &controller.GenericController{ + Reconcile: func(key types.ReconcileKey) error { + fmt.Printf("Reconciling Pod %s\n", key) + return nil + }, + } + err := c.Watch(&appsv1.ReplicaSet{}) + if err != nil { + log.Fatalf("%v", err) + } + err = c.WatchTransformationKeysOf(&corev1.Pod{}, + func(i interface{}) []types.ReconcileKey { + p, ok := i.(*corev1.Pod) + if !ok { + return []types.ReconcileKey{} + } + + // Find multiple parents based off the name + return []types.ReconcileKey{ + {p.Namespace, strings.Split(p.Name, "-")[0] + "-parent-1"}, + {p.Namespace, strings.Split(p.Name, "-")[0] + "-parent-2"}, + } + }, + ) + if err != nil { + log.Fatalf("%v", err) + } + controller.AddController(c) + + // One time for program + controller.RunInformersAndControllers(run.CreateRunArguments()) +} diff --git a/pkg/controller/example_watchandmaptocontroller_test.go b/pkg/controller/example_watchandmaptocontroller_test.go new file mode 100644 index 0000000000..191193d8b4 --- /dev/null +++ b/pkg/controller/example_watchandmaptocontroller_test.go @@ -0,0 +1,58 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller_test + +import ( + "flag" + "fmt" + "log" + + "github.com/kubernetes-sigs/kubebuilder/pkg/config" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/eventhandlers" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/types" + "github.com/kubernetes-sigs/kubebuilder/pkg/inject/run" + corev1 "k8s.io/api/core/v1" +) + +func ExampleGenericController_WatchControllerOf() { + // One time setup for program + flag.Parse() + informerFactory := config.GetKubernetesInformersOrDie() + if err := controller.AddInformerProvider(&corev1.Pod{}, informerFactory.Core().V1().Pods()); err != nil { + log.Fatalf("Could not set informer %v", err) + } + + // Per-controller setup + c := &controller.GenericController{ + Reconcile: func(key types.ReconcileKey) error { + fmt.Printf("Reconciling Pod %s\n", key) + return nil + }, + } + fn := func(k types.ReconcileKey) (interface{}, error) { + return informerFactory.Apps().V1().ReplicaSets().Lister().ReplicaSets(k.Namespace).Get(k.Name) + } + err := c.WatchControllerOf(&corev1.Pod{}, eventhandlers.Path{fn}) + if err != nil { + log.Fatalf("%v", err) + } + controller.AddController(c) + + // One time for program + controller.RunInformersAndControllers(run.CreateRunArguments()) +} diff --git a/pkg/controller/example_watchchannel_test.go b/pkg/controller/example_watchchannel_test.go new file mode 100644 index 0000000000..9e54172f68 --- /dev/null +++ b/pkg/controller/example_watchchannel_test.go @@ -0,0 +1,44 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller_test + +import ( + "fmt" + "log" + + "github.com/kubernetes-sigs/kubebuilder/pkg/controller" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/types" + "github.com/kubernetes-sigs/kubebuilder/pkg/inject/run" +) + +func ExampleGenericController_WatchChannel() { + podkeys := make(chan string) + + c := &controller.GenericController{ + Reconcile: func(key types.ReconcileKey) error { + fmt.Printf("Reconciling Pod %s\n", key) + return nil + }, + } + if err := c.WatchChannel(podkeys); err != nil { + log.Fatalf("%v", err) + } + controller.AddController(c) + controller.RunInformersAndControllers(run.CreateRunArguments()) + + podkeys <- "namespace/pod-name" +} diff --git a/pkg/controller/flags.go b/pkg/controller/flags.go new file mode 100644 index 0000000000..e150354dd7 --- /dev/null +++ b/pkg/controller/flags.go @@ -0,0 +1,17 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller diff --git a/pkg/controller/informers/doc.go b/pkg/controller/informers/doc.go new file mode 100644 index 0000000000..dffc92bcc8 --- /dev/null +++ b/pkg/controller/informers/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// The informers defines a registry for sharing informers +package informers diff --git a/pkg/controller/informers/informersindex.go b/pkg/controller/informers/informersindex.go new file mode 100644 index 0000000000..3a88e22353 --- /dev/null +++ b/pkg/controller/informers/informersindex.go @@ -0,0 +1,67 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package informers + +import ( + "fmt" + "reflect" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/cache" +) + +type InformerProvider interface { + Informer() cache.SharedIndexInformer +} + +type InformerGetter interface { + GetInformer(object metav1.Object) cache.SharedInformer +} + +// InformerRegistry contains a map of +type InformerRegistry map[string]InformerProvider + +// Insert adds an SharedInformer to the Map +func (im InformerRegistry) Insert(object metav1.Object, informerprovider InformerProvider) error { + if _, found := im[reflect.TypeOf(object).String()]; found { + return fmt.Errorf("Cannot Insert informer for %T, already exists", object) + } + im[reflect.TypeOf(object).String()] = informerprovider + return nil +} + +// Get gets an SharedInformer from the Map +func (im InformerRegistry) Get(object metav1.Object) cache.SharedInformer { + if v, found := im[reflect.TypeOf(object).String()]; found { + return v.Informer() + } + return nil +} + +func (im InformerRegistry) GetInformerProvider(object metav1.Object) InformerProvider { + if v, found := im[reflect.TypeOf(object).String()]; found { + return v + } + return nil +} + +// RunAll runs all of the shared informers +func (im InformerRegistry) RunAll(stop <-chan struct{}) { + for _, i := range im { + go i.Informer().Run(stop) + } +} diff --git a/pkg/controller/informers/informersindex_test.go b/pkg/controller/informers/informersindex_test.go new file mode 100644 index 0000000000..a1792a84c6 --- /dev/null +++ b/pkg/controller/informers/informersindex_test.go @@ -0,0 +1,26 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package informers + +import ( + . "github.com/onsi/ginkgo" + //. "github.com/onsi/gomega" +) + +var _ = Describe("Informersmap", func() { + +}) diff --git a/pkg/controller/listeningqueue.go b/pkg/controller/listeningqueue.go new file mode 100644 index 0000000000..6d7dc7fb37 --- /dev/null +++ b/pkg/controller/listeningqueue.go @@ -0,0 +1,75 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "fmt" + + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/eventhandlers" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/informers" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" +) + +// listeningQueue registers event providers and maps the observed events into strings that it then enqueues. +type listeningQueue struct { + // RateLimitingInterface is the workqueue backing the listeningQueue + workqueue.RateLimitingInterface + + // Name is the Name of the queue + Name string + + // informerProvider contains a InformerGetter that is able to lookup informers for objects from their type + informerProvider informers.InformerGetter + + // synced is a slice of functions that return whether or not all informers have been synced + synced []cache.InformerSynced +} + +// watchChannel enqueues message from a channel +func (q *listeningQueue) watchChannel(source <-chan string) error { + go func() { + for msg := range source { + q.AddRateLimited(msg) + } + }() + return nil +} + +// addEventHandler uses the provider functions to add an event handler for events to objects matching obj's type +func (q *listeningQueue) addEventHandler(obj metav1.Object, eh eventhandlers.EventHandler) error { + + i, err := q.lookupInformer(obj) + if err != nil { + return err + } + fns := eh.Get(q.RateLimitingInterface) + q.synced = append(q.synced, i.HasSynced) + i.AddEventHandler(fns) + return nil +} + +// lookupInformer returns the SharedInformer for the type if found, otherwise exists +func (q *listeningQueue) lookupInformer(obj metav1.Object) (cache.SharedInformer, error) { + i := q.informerProvider.GetInformer(obj) + if i == nil { + return i, fmt.Errorf("Could not find SharedInformer for %T in %s. Must register with "+ + "// +kubebuilder:informer:", obj, q.informerProvider) + } + return i, nil +} diff --git a/pkg/controller/listeningqueue_test.go b/pkg/controller/listeningqueue_test.go new file mode 100644 index 0000000000..317f981917 --- /dev/null +++ b/pkg/controller/listeningqueue_test.go @@ -0,0 +1,72 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "time" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/test" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/util/workqueue" +) + +var _ = Describe("ListeningQueue", func() { + var ( + instance listeningQueue + manager *ControllerManager + fakePodInformer *test.FakeInformer + fakeReplicaSetInformer *test.FakeInformer + ) + + BeforeEach(func() { + // Create a new informers map with fake informers + manager = &ControllerManager{} + fakePodInformer = &test.FakeInformer{Synced: true} + manager.AddInformerProvider(&corev1.Pod{}, fakePodInformer) + + fakeReplicaSetInformer = &test.FakeInformer{Synced: true} + manager.AddInformerProvider(&appsv1.ReplicaSet{}, fakeReplicaSetInformer) + + // Create a new listeningQueue + instance = listeningQueue{ + RateLimitingInterface: workqueue.NewNamedRateLimitingQueue( + workqueue.DefaultControllerRateLimiter(), "test"), + informerProvider: manager, + } + }) + + Describe("Listening to a Channel", func() { + Context("Where a message is sent", func() { + It("should enqueue the message", func() { + // Listen for Pod changes + c := make(chan string) + Expect(instance.watchChannel(c)).Should(Succeed()) + c <- "default/test-pod" + + Eventually(instance.Len, time.Second*1).Should(Equal(1)) + key, shutdown := instance.Get() + Expect(shutdown).To(Equal(false)) + Expect(key).To(Equal("default/test-pod")) + Expect(instance.Len()).To(Equal(0)) + }) + }) + }) +}) diff --git a/pkg/controller/manager.go b/pkg/controller/manager.go new file mode 100644 index 0000000000..1acade308d --- /dev/null +++ b/pkg/controller/manager.go @@ -0,0 +1,192 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "fmt" + "os" + "reflect" + "runtime" + "strings" + "sync" + + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/informers" + "github.com/kubernetes-sigs/kubebuilder/pkg/inject/run" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/cache" +) + +type controllers []*GenericController + +func (c controllers) runAll(options run.RunArguments) { + for _, controller := range c { + go controller.run(options) + } +} + +var ( + // DefaultManager is the ControllerManager used by the package functions + DefaultManager = &defaultManager + defaultManager = ControllerManager{} +) + +// ControllerManager registers shared informers and controllers +type ControllerManager struct { + sharedInformersByResource informers.InformerRegistry + controllers controllers + once sync.Once +} + +func (m *ControllerManager) init() { + m.controllers = controllers{} + m.sharedInformersByResource = informers.InformerRegistry{} +} + +// AddInformerProvider registers a new shared SharedIndexInformer under the object type. +// SharedIndexInformer will be RunInformersAndControllers by calling RunInformersAndControllers on the ControllerManager. +func (m *ControllerManager) AddInformerProvider(object metav1.Object, informerProvider informers.InformerProvider) error { + m.once.Do(m.init) + return m.sharedInformersByResource.Insert(object, informerProvider) +} + +// AddInformerProvider registers a new shared SharedIndexInformer under the object type. +// SharedIndexInformer will be RunInformersAndControllers by calling RunInformersAndControllers on the ControllerManager. +func AddInformerProvider(object metav1.Object, informerProvider informers.InformerProvider) error { + return DefaultManager.AddInformerProvider(object, informerProvider) +} + +// GetInformer returns the Informer for an object +func (m *ControllerManager) GetInformer(object metav1.Object) cache.SharedInformer { + m.once.Do(m.init) + si := m.sharedInformersByResource.Get(object) + if si == nil { + warningMissingInformer(object) + } + return si +} + +// GetInformerProvider returns the InformerProvider for the object type +func (m *ControllerManager) GetInformerProvider(object metav1.Object) informers.InformerProvider { + m.once.Do(m.init) + si := m.sharedInformersByResource.GetInformerProvider(object) + if si == nil { + warningMissingInformer(object) + } + return si +} + +// GetInformerProvider returns the InformerProvider for the object type. +// Use this to get Listers for objects. +func GetInformerProvider(object metav1.Object) informers.InformerProvider { + return DefaultManager.GetInformerProvider(object) +} + +// NewController registers a new controller to be run.. +func (m *ControllerManager) AddController(controller *GenericController) { + m.once.Do(m.init) + m.controllers = append(m.controllers, controller) +} + +// GetController returns a registered controller with the name +func (m *ControllerManager) GetController(name string) *GenericController { + m.once.Do(m.init) + for _, c := range m.controllers { + if c.Name == name { + return c + } + } + return nil +} + +// NewController registers a new controller to be run. +func AddController(controller *GenericController) { + DefaultManager.AddController(controller) +} + +// RunInformersAndControllers starts the registered informers and controllers. +// Sets options.Parallelism to 1 if it is lt 1 +// Creates a new channel for options.Stop if it is nil +func (m *ControllerManager) RunInformersAndControllers(options run.RunArguments) { + m.once.Do(m.init) + if options.ControllerParallelism < 1 { + options.ControllerParallelism = 1 + } + if options.Stop == nil { + options.Stop = make(<-chan struct{}) + } + m.sharedInformersByResource.RunAll(options.Stop) + m.controllers.runAll(options) +} + +// RunInformersAndControllers runs all of the informers and controllers +func RunInformersAndControllers(options run.RunArguments) { + DefaultManager.RunInformersAndControllers(options) +} + +// String prints the registered shared informers +func (m *ControllerManager) String() string { + return fmt.Sprintf("ControllerManager SharedInformers: %v", m.sharedInformersByResource) +} + +// warningMissingInformer prints a warning message to stderr that an informer was not registered +func warningMissingInformer(obj interface{}) { + // Get the type of the object + t := reflect.TypeOf(reflect.Indirect(reflect.ValueOf(obj)).Interface()) + + // Parse the GVK from the object + path := t.PkgPath() + groupversion := strings.Split(path, "/") + group := groupversion[len(groupversion)-2] + version := groupversion[len(groupversion)-1] + kind := t.Name() + + // Create a helpful error message + msg := fmt.Sprintf("\nWARNING: %s\nWARNING: Informer for %s.%s.%s not registered! "+ + "Must register informer with a // +kubebuilder:informers:group=%s,version=%s,kind=%s annotation on the "+ + "Controller struct and then run `kubebuilder generate`.\n", + provideControllerLine(), group, version, kind, group, version, kind) + fmt.Fprint(os.Stderr, msg) +} + +// provideControllerLine returns a string with the file and line number where "ProvideController" was called +// in the call stack +func provideControllerLine() string { + ok := true + var file string + var pc uintptr + var line int + for i := 0; ok; i++ { + pc, file, line, ok = runtime.Caller(i) + if !ok { + break + } + f := runtime.FuncForPC(pc) + if f == nil { + ok = false + break + } + parts := strings.Split(f.Name(), ".") + fn := parts[len(parts)-1] + if fn == "ProvideController" { + break + } + } + if ok { + return fmt.Sprintf("%s:%v", file, line) + } + return "" +} diff --git a/pkg/controller/metrics/doc.go b/pkg/controller/metrics/doc.go new file mode 100644 index 0000000000..137a4474a4 --- /dev/null +++ b/pkg/controller/metrics/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// The metrics package defines controller runtime metrics +package metrics diff --git a/pkg/controller/metrics/metrics.go b/pkg/controller/metrics/metrics.go new file mode 100644 index 0000000000..e345b9e829 --- /dev/null +++ b/pkg/controller/metrics/metrics.go @@ -0,0 +1,39 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package metrics + +// Metrics contains runtime metrics about the controller +type Metrics struct { + // UncompletedReconcileTs is a sorted slice of start timestamps from the currently running reconcile loops + // This can be used to calculate stats such as - shortest running reconcile, longest running reconcile, mean time + UncompletedReconcileTs []int64 + + // QueueLength is the number of unprocessed messages in the queue + QueueLength int + + // MeanCompletionTime gives the average reconcile time over the past 10m + // TODO: Implement this + MeanReconcileTime int + + // ReconcileRate gives the average reconcile rate over the past 10m + // TODO: Implement this + ReconcileRate int + + // QueueLength is the average queue length over the past 10m + // TODO: Implement this + MeanQueueLength int +} diff --git a/pkg/controller/predicates/predicates.go b/pkg/controller/predicates/predicates.go new file mode 100644 index 0000000000..7f2693e742 --- /dev/null +++ b/pkg/controller/predicates/predicates.go @@ -0,0 +1,83 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package predicates + +import ( + "fmt" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var ( + ResourceVersionChanged = ResourceVersionChangedPredicate{} +) + +type Predicate interface { + HandleUpdate(old, new interface{}) bool + HandleDelete(obj interface{}) bool + HandleCreate(obj interface{}) bool +} + +type TrueMixin struct{} + +func (TrueMixin) HandleUpdate(old, new interface{}) bool { + return true +} + +func (TrueMixin) HandleDelete(obj interface{}) bool { + return true +} + +func (TrueMixin) HandleCreate(obj interface{}) bool { + return true +} + +type FalseMixin struct{} + +func (FalseMixin) HandleUpdate(old, new interface{}) bool { + return false +} + +func (FalseMixin) HandleDelete(obj interface{}) bool { + return false +} + +func (FalseMixin) HandleCreate(obj interface{}) bool { + return false +} + +type ResourceVersionChangedPredicate struct { + TrueMixin +} + +func (ResourceVersionChangedPredicate) HandleUpdate(old, new interface{}) bool { + oldObject, ok := old.(metav1.Object) + if !ok { + fmt.Errorf("Cannot handle %T because old is not an Object: %v\n", oldObject, oldObject) + return false + } + newObject, ok := new.(metav1.Object) + if !ok { + fmt.Errorf("Cannot handle %T because new is not an Object: %v\n", newObject, newObject) + return false + } + + if oldObject.GetResourceVersion() == newObject.GetResourceVersion() { + // Periodic resync will send update events for all resources Deployments. + return false + } + return true +} diff --git a/pkg/controller/predicates/predicates_suite_test.go b/pkg/controller/predicates/predicates_suite_test.go new file mode 100644 index 0000000000..abe50afa6e --- /dev/null +++ b/pkg/controller/predicates/predicates_suite_test.go @@ -0,0 +1,29 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package predicates + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "testing" +) + +func TestPredicates(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Predicates Suite") +} diff --git a/pkg/controller/predicates/predicates_test.go b/pkg/controller/predicates/predicates_test.go new file mode 100644 index 0000000000..e91785e359 --- /dev/null +++ b/pkg/controller/predicates/predicates_test.go @@ -0,0 +1,117 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package predicates + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("Predicates", func() { + var () + + BeforeEach(func() { + }) + + Describe("When checking the TrueMixin Predicate", func() { + It("should return true for Add", func() { + Expect(TrueMixin{}.HandleCreate("")).Should(BeTrue()) + }) + It("should return true for Update", func() { + Expect(TrueMixin{}.HandleUpdate("", "")).Should(BeTrue()) + }) + It("should return true for Delete", func() { + Expect(TrueMixin{}.HandleDelete("")).Should(BeTrue()) + }) + }) + + Describe("When checking the FalseMixin Predicate", func() { + It("should return true for Add", func() { + Expect(FalseMixin{}.HandleCreate("")).Should(BeFalse()) + }) + It("should return true for Update", func() { + Expect(FalseMixin{}.HandleUpdate("", "")).Should(BeFalse()) + }) + It("should return true for Delete", func() { + Expect(FalseMixin{}.HandleDelete("")).Should(BeFalse()) + }) + }) + + Describe("When checking a ResourceVersionChangedPredicate", func() { + Context("Where the old object doesn't have a ResourceVersion", func() { + It("should return false", func() { + instance := ResourceVersionChangedPredicate{} + Expect(instance.HandleDelete("")).Should(BeTrue()) + Expect(instance.HandleCreate("")).Should(BeTrue()) + Expect(instance.HandleUpdate(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + ResourceVersion: "1", + }, + }, "")).Should(BeFalse()) + }) + }) + + Context("Where the new object doesn't have a ResourceVersion", func() { + It("should return false", func() { + instance := ResourceVersionChangedPredicate{} + Expect(instance.HandleDelete("")).Should(BeTrue()) + Expect(instance.HandleCreate("")).Should(BeTrue()) + Expect(instance.HandleUpdate("", &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + ResourceVersion: "1", + }, + })).Should(BeFalse()) + }) + }) + + Context("Where the ResourceVersion hasn't changed", func() { + It("should return false", func() { + instance := ResourceVersionChangedPredicate{} + Expect(instance.HandleDelete("")).Should(BeTrue()) + Expect(instance.HandleCreate("")).Should(BeTrue()) + Expect(instance.HandleUpdate(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + ResourceVersion: "1", + }, + }, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + ResourceVersion: "1", + }, + })).Should(BeFalse()) + }) + }) + + Context("Where the ResourceVersion has changed", func() { + It("should return true", func() { + instance := ResourceVersionChangedPredicate{} + Expect(instance.HandleDelete("")).Should(BeTrue()) + Expect(instance.HandleCreate("")).Should(BeTrue()) + Expect(instance.HandleUpdate(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + ResourceVersion: "1", + }, + }, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + ResourceVersion: "2", + }, + })).Should(BeTrue()) + }) + }) + }) +}) diff --git a/pkg/controller/test/doc.go b/pkg/controller/test/doc.go new file mode 100644 index 0000000000..6af621ddba --- /dev/null +++ b/pkg/controller/test/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// The test package contains fake informers for testing controllers +package test diff --git a/pkg/controller/test/util.go b/pkg/controller/test/util.go new file mode 100644 index 0000000000..d6d7c31b6d --- /dev/null +++ b/pkg/controller/test/util.go @@ -0,0 +1,101 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package test + +import ( + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/cache" +) + +var _ cache.SharedIndexInformer = &FakeInformer{} + +// FakeInformer provides fake Informer functionality for testing +type FakeInformer struct { + // Synced is returned by the HasSynced functions to implement the Informer interface + Synced bool + + // RunCount is incremented each time RunInformersAndControllers is called + RunCount int + + handlers []cache.ResourceEventHandler +} + +func (f *FakeInformer) AddIndexers(indexers cache.Indexers) error { + return nil +} + +func (f *FakeInformer) GetIndexer() cache.Indexer { + return nil +} + +func (f *FakeInformer) Informer() cache.SharedIndexInformer { + return f +} + +// HasSynced implements the Informer interface. Returns f.Synced +func (f *FakeInformer) HasSynced() bool { + return f.Synced +} + +// AddEventHandler implements the Informer interface. +func (f *FakeInformer) AddEventHandler(handler cache.ResourceEventHandler) { + f.handlers = append(f.handlers, handler) +} + +// RunInformersAndControllers implements the Informer interface. Increments f.RunCount +func (f *FakeInformer) Run(<-chan struct{}) { + f.RunCount++ +} + +// Add fakes an Add event for obj +func (f *FakeInformer) Add(obj metav1.Object) { + for _, h := range f.handlers { + h.OnAdd(obj) + } +} + +// Add fakes an Update event for obj +func (f *FakeInformer) Update(oldObj, newObj metav1.Object) { + for _, h := range f.handlers { + h.OnUpdate(oldObj, newObj) + } +} + +// Add fakes an Delete event for obj +func (f *FakeInformer) Delete(obj metav1.Object) { + for _, h := range f.handlers { + h.OnDelete(obj) + } +} + +func (f *FakeInformer) AddEventHandlerWithResyncPeriod(handler cache.ResourceEventHandler, resyncPeriod time.Duration) { + +} + +func (f *FakeInformer) GetStore() cache.Store { + return nil +} + +func (f *FakeInformer) GetController() cache.Controller { + return nil +} + +func (f *FakeInformer) LastSyncResourceVersion() string { + return "" +} diff --git a/pkg/controller/types/doc.go b/pkg/controller/types/doc.go new file mode 100644 index 0000000000..c1000aaaf1 --- /dev/null +++ b/pkg/controller/types/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// The types package declares types used by the controller package +package types diff --git a/pkg/controller/types/types.go b/pkg/controller/types/types.go new file mode 100644 index 0000000000..10411e41e1 --- /dev/null +++ b/pkg/controller/types/types.go @@ -0,0 +1,53 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package types + +import ( + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" +) + +// ReconcileFn takes the key of an object and reconciles its desired and observed state. +type ReconcileFn func(ReconcileKey) error + +// HandleFnProvider returns cache.ResourceEventHandler that may enqueue messages +type HandleFnProvider func(workqueue.RateLimitingInterface) cache.ResourceEventHandler + +// ReconcileKey provides a lookup key for a Kubernetes object. +type ReconcileKey struct { + // Namespace is the namespace of the object. Empty for non-namespaced objects. + Namespace string + + // Name is the name of the object. + Name string +} + +func (r ReconcileKey) String() string { + if r.Namespace == "" { + return r.Name + } + return r.Namespace + "/" + r.Name +} + +// ParseReconcileKey returns the ReconcileKey that has been encoded into a string. +func ParseReconcileKey(key string) (ReconcileKey, error) { + namespace, name, err := cache.SplitMetaNamespaceKey(key) + if err != nil { + return ReconcileKey{}, err + } + return ReconcileKey{Name: name, Namespace: namespace}, nil +} diff --git a/pkg/ctrl/common/apimachinery.go b/pkg/ctrl/common/apimachinery.go new file mode 100644 index 0000000000..47767960ff --- /dev/null +++ b/pkg/ctrl/common/apimachinery.go @@ -0,0 +1,66 @@ +package common + +import ( + "fmt" + + "k8s.io/client-go/rest" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" + "k8s.io/apimachinery/pkg/runtime/serializer" +) + +// NewDiscoveryRESTMapper constructs a new RESTMapper based on discovery +// information fetched by a new client with the given config. +func NewDiscoveryRESTMapper(c *rest.Config) (meta.RESTMapper, error) { + // Get a mapper + dc := discovery.NewDiscoveryClientForConfigOrDie(c) + gr, err := discovery.GetAPIGroupResources(dc) + if err != nil { + return nil, err + } + return discovery.NewRESTMapper(gr, dynamic.VersionInterfaces), nil +} + +// GVKForObject finds the GroupVersionKind associated with the given object, if there is only a single such GVK. +func GVKForObject(obj runtime.Object, scheme *runtime.Scheme) (schema.GroupVersionKind, error) { + gvks, isUnversioned, err := scheme.ObjectKinds(obj) + if err != nil { + return schema.GroupVersionKind{}, err + } + if isUnversioned { + return schema.GroupVersionKind{}, fmt.Errorf("cannot create a new informer for the unversioned type %T", obj) + } + + if len(gvks) < 1 { + return schema.GroupVersionKind{}, fmt.Errorf("no group-version-kinds associated with type %T", obj) + } + if len(gvks) > 1 { + // this should only trigger for things like metav1.XYZ -- + // normal versioned types should be fine + return schema.GroupVersionKind{}, fmt.Errorf( + "multiple group-version-kinds associated with type %T, refusing to guess at one", obj) + } + return gvks[0], nil +} + +// RESTClientForObject constructs a new rest.Interface capable of accessing the resource associated +// with the given GroupVersionKind. +func RESTClientForGVK(gvk schema.GroupVersionKind, baseConfig *rest.Config, codecs serializer.CodecFactory) (rest.Interface, error) { + gv := gvk.GroupVersion() + + cfg := rest.CopyConfig(baseConfig) + cfg.GroupVersion = &gv + if gvk.Group == "" { + cfg.APIPath = "/api" + } else { + cfg.APIPath = "/apis" + } + cfg.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: codecs} + if cfg.UserAgent == "" { + cfg.UserAgent = rest.DefaultKubernetesUserAgent() + } + return rest.RESTClientFor(cfg) +} diff --git a/pkg/ctrl/controller.go b/pkg/ctrl/controller.go new file mode 100644 index 0000000000..a04ceba7ef --- /dev/null +++ b/pkg/ctrl/controller.go @@ -0,0 +1,231 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ctrl + +import ( + "fmt" + "sync" + "time" + + "github.com/kubernetes-sigs/kubebuilder/pkg/client" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/eventhandler" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/predicate" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/reconcile" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/source" + "github.com/kubernetes-sigs/kubebuilder/pkg/informer" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" + + logf "github.com/kubernetes-sigs/kubebuilder/pkg/log" +) + +var log = logf.KBLog.WithName("controller").WithName("controller") + +// ControllerArgs are the arguments for creating a new Controller +type ControllerArgs struct { + // Name is used to uniquely identify a controller in tracing, logging and monitoring. Name is required. + Name string + + // maxConcurrentReconciles is the maximum number of concurrent Reconciles which can be run. Defaults to 1. + MaxConcurrentReconciles int +} + +// Controllers are work queues that watch for changes to objects (i.e. Create / Update / Delete events) and +// then reconcile an object (i.e. make changes to ensure the system state matches what is specified in the object). +type Controller interface { + // Watch takes events provided by a Source and uses the EventHandler to enqueue ReconcileRequests in + // response to the events. + // + // Watch may be provided one or more Predicates to filter events before they are given to the EventHandler. + // Events will be passed to the EventHandler iff all provided Predicates evaluate to true. + Watch(src source.Source, evthdler eventhandler.EventHandler, prct ...predicate.Predicate) error + + // Start starts the controller. Start blocks until stop is closed or a controller has an error starting. + Start(stop <-chan struct{}) error +} + +var _ Controller = &controller{} + +type controller struct { + // name is used to uniquely identify a controller in tracing, logging and monitoring. name is required. + name string + + // maxConcurrentReconciles is the maximum number of concurrent Reconciles which can be run. Defaults to 1. + maxConcurrentReconciles int + + // reconcile is a function that can be called at any time with the name / Namespace of an object and + // ensures that the state of the system matches the state specified in the object. + // Defaults to the DefaultReconcileFunc. + reconcile reconcile.Reconcile + + // client is a lazily initialized client. The controllerManager will initialize this when Start is called. + client client.Interface + + // scheme is injected by the controllerManager when controllerManager.Start is called + scheme *runtime.Scheme + + // informers are injected by the controllerManager when controllerManager.Start is called + informers informer.Informers + + // config is the rest.config used to talk to the apiserver. Defaults to one of in-cluster, environment variable + // specified, or the ~/.kube/config. + config *rest.Config + + // queue is an listeningQueue that listens for events from Informers and adds object keys to + // the queue for processing + queue workqueue.RateLimitingInterface + + // once ensures unspecified fields get default values + once sync.Once + + // inject is used to inject dependencies into other objects such as Sources, EventHandlers and Predicates + inject func(i interface{}) error + + // mu is used to synchronize controller setup + mu sync.Mutex + + // TODO(pwittrock): Consider initializing a logger with the controller name as the tag +} + +func (c *controller) Watch(src source.Source, evthdler eventhandler.EventHandler, prct ...predicate.Predicate) error { + c.mu.Lock() + defer c.mu.Unlock() + + // Inject cache into arguments + if err := c.inject(src); err != nil { + return err + } + if err := c.inject(evthdler); err != nil { + return err + } + for _, pr := range prct { + if err := c.inject(pr); err != nil { + return err + } + } + + // TODO(pwittrock): wire in predicates + + log.Info("Starting EventSource", "controller", c.name, "Source", src) + return src.Start(evthdler, c.queue) +} + +func (c *controller) Start(stop <-chan struct{}) error { + c.mu.Lock() + defer c.mu.Unlock() + + // TODO)(pwittrock): Reconsider HandleCrash + defer utilruntime.HandleCrash() + defer c.queue.ShutDown() + + // Start the SharedIndexInformer factories to begin populating the SharedIndexInformer caches + log.Info("Starting controller", "controller", c.name) + + // Wait for the caches to be synced before starting workers + allInformers := c.informers.KnownInformersByType() + syncedFuncs := make([]cache.InformerSynced, 0, len(allInformers)) + for _, informer := range allInformers { + syncedFuncs = append(syncedFuncs, informer.HasSynced) + } + if ok := cache.WaitForCacheSync(stop, syncedFuncs...); !ok { + err := fmt.Errorf("failed to wait for %s caches to sync", c.name) + log.Error(err, "Could not wait for Cache to sync", "controller", c.name) + return err + } + + // Launch two workers to process resources + log.Info("Starting workers", "controller", c.name, "WorkerCount", c.maxConcurrentReconciles) + for i := 0; i < c.maxConcurrentReconciles; i++ { + // Continually process work items + go wait.Until(func() { + // TODO(pwittrock): Should we really use wait.Until to continuously restart this if it exits? + for c.processNextWorkItem() { + } + }, time.Second, stop) + } + + <-stop + log.Info("Stopping workers", "controller", c.name) + return nil +} + +// processNextWorkItem will read a single work item off the workqueue and +// attempt to process it, by calling the syncHandler. +func (c *controller) processNextWorkItem() bool { + // This code copy-pasted from the sample-controller. + + obj, shutdown := c.queue.Get() + if obj == nil { + log.Error(nil, "Encountered nil ReconcileRequest", "Object", obj) + c.queue.Forget(obj) + } + + if shutdown { + // Return false, take a break before starting again. But Y tho? + return false + } + + // We call Done here so the workqueue knows we have finished + // processing this item. We also must remember to call Forget if we + // do not want this work item being re-queued. For example, we do + // not call Forget if a transient error occurs, instead the item is + // put back on the workqueue and attempted again after a back-off + // period. + defer c.queue.Done(obj) + var req reconcile.ReconcileRequest + var ok bool + if req, ok = obj.(reconcile.ReconcileRequest); !ok { + // As the item in the workqueue is actually invalid, we call + // Forget here else we'd go into a loop of attempting to + // process a work item that is invalid. + c.queue.Forget(obj) + log.Error(nil, "Queue item was not a ReconcileRequest", + "controller", c.name, "Type", fmt.Sprintf("%T", obj), "Value", obj) + // Return true, don't take a break + return true + } + + // RunInformersAndControllers the syncHandler, passing it the namespace/name string of the + // resource to be synced. + if result, err := c.reconcile.Reconcile(req); err != nil { + c.queue.AddRateLimited(req) + log.Error(nil, "reconcile error", "controller", c.name, "ReconcileRequest", req) + + // TODO(pwittrock): FTO Returning an error here seems to back things off for a second before restarting + // the loop through wait.Util. + // Return false, take a break. But y tho? + return false + } else if result.Requeue { + c.queue.AddRateLimited(req) + // Return true, don't take a break + return true + } + + // Finally, if no error occurs we Forget this item so it does not + // get queued again until another change happens. + c.queue.Forget(obj) + + // TODO(directxman12): What does 1 mean? Do we want level constants? Do we want levels at all? + log.V(1).Info("Successfully Reconciled", "controller", c.name, "ReconcileRequest", req) + + // Return true, don't take a break + return true +} diff --git a/pkg/ctrl/controller_integration_test.go b/pkg/ctrl/controller_integration_test.go new file mode 100644 index 0000000000..7d76e396eb --- /dev/null +++ b/pkg/ctrl/controller_integration_test.go @@ -0,0 +1,159 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ctrl_test + +import ( + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/eventhandler" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/reconcile" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/source" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("controller", func() { + var reconciled chan reconcile.ReconcileRequest + var stop chan struct{} + + BeforeEach(func() { + stop = make(chan struct{}) + reconciled = make(chan reconcile.ReconcileRequest) + Expect(cfg).NotTo(BeNil()) + }) + + AfterEach(func() { + close(stop) + }) + + Describe("controller", func() { + // TODO(directxman12): write a whole suite of controller-client interaction tests + + It("should reconcile", func(done Done) { + By("Creating the ControllerManager") + cm, err := ctrl.NewControllerManager(ctrl.ControllerManagerArgs{Config: cfg}) + Expect(err).NotTo(HaveOccurred()) + + By("Creating the Controller") + instance, err := cm.NewController(ctrl.ControllerArgs{Name: "foo-controller"}, reconcile.ReconcileFunc( + func(request reconcile.ReconcileRequest) (reconcile.ReconcileResult, error) { + reconciled <- request + return reconcile.ReconcileResult{}, nil + })) + Expect(err).NotTo(HaveOccurred()) + + By("Watching Resources") + err = instance.Watch(&source.KindSource{Type: &appsv1.ReplicaSet{}}, &eventhandler.EnqueueOwnerHandler{ + OwnerType: &appsv1.Deployment{}, + }) + Expect(err).NotTo(HaveOccurred()) + + err = instance.Watch(&source.KindSource{Type: &appsv1.Deployment{}}, &eventhandler.EnqueueHandler{}) + Expect(err).NotTo(HaveOccurred()) + + By("Starting the ControllerManager") + go func() { + defer GinkgoRecover() + Expect(cm.Start(stop)).NotTo(HaveOccurred()) + }() + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "deployment-name"}, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"foo": "bar"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"foo": "bar"}}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + } + expectedReconcileRequest := reconcile.ReconcileRequest{NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "deployment-name", + }} + + By("Invoking Reconciling for Create") + deployment, err = clientset.AppsV1().Deployments("default").Create(deployment) + Expect(err).NotTo(HaveOccurred()) + Expect(<-reconciled).To(Equal(expectedReconcileRequest)) + + By("Invoking Reconciling for Update") + newDeployment := deployment.DeepCopy() + newDeployment.Labels = map[string]string{"foo": "bar"} + newDeployment, err = clientset.AppsV1().Deployments("default").Update(newDeployment) + Expect(err).NotTo(HaveOccurred()) + Expect(<-reconciled).To(Equal(expectedReconcileRequest)) + + By("Invoking Reconciling for an OwnedObject when it is created") + replicaset := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rs-name", + OwnerReferences: []metav1.OwnerReference{ + *metav1.NewControllerRef(deployment, schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }), + }, + }, + Spec: appsv1.ReplicaSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"foo": "bar"}, + }, + Template: deployment.Spec.Template, + }, + } + replicaset, err = clientset.AppsV1().ReplicaSets("default").Create(replicaset) + Expect(err).NotTo(HaveOccurred()) + Expect(<-reconciled).To(Equal(expectedReconcileRequest)) + + By("Invoking Reconciling for an OwnedObject when it is updated") + newReplicaset := replicaset.DeepCopy() + newReplicaset.Labels = map[string]string{"foo": "bar"} + newReplicaset, err = clientset.AppsV1().ReplicaSets("default").Update(newReplicaset) + Expect(err).NotTo(HaveOccurred()) + Expect(<-reconciled).To(Equal(expectedReconcileRequest)) + + By("Invoking Reconciling for an OwnedObject when it is deleted") + err = clientset.AppsV1().ReplicaSets("default").Delete(replicaset.Name, &metav1.DeleteOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(<-reconciled).To(Equal(expectedReconcileRequest)) + + By("Invoking Reconciling for Delete") + err = clientset.AppsV1().Deployments("default"). + Delete("deployment-name", &metav1.DeleteOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(<-reconciled).To(Equal(expectedReconcileRequest)) + + close(done) + }, 5) + }) +}) diff --git a/pkg/ctrl/controller_manager.go b/pkg/ctrl/controller_manager.go new file mode 100644 index 0000000000..8696c95968 --- /dev/null +++ b/pkg/ctrl/controller_manager.go @@ -0,0 +1,245 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ctrl + +import ( + "fmt" + "sync" + + "github.com/kubernetes-sigs/kubebuilder/pkg/client" + "github.com/kubernetes-sigs/kubebuilder/pkg/config" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/common" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/inject" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/reconcile" + "github.com/kubernetes-sigs/kubebuilder/pkg/informer" + "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/util/workqueue" +) + +// ControllerManager initializes shared dependencies such as Caches and Clients, and starts Controllers. +// +// Dependencies may be retrieved from the ControllerManager using the Get* functions +type ControllerManager interface { + // NewController creates a new initialized Controller with the Reconcile function + // and registers it with the ControllerManager. + NewController(ControllerArgs, reconcile.Reconcile) (Controller, error) + + // Start starts all registered Controllers and blocks until the Stop channel is closed. + // Returns an error if there is an error starting any controller. + Start(<-chan struct{}) error + + // GetConfig returns an initialized Config + GetConfig() *rest.Config + + // GetScheme returns and initialized Scheme + GetScheme() *runtime.Scheme + + // GetClient returns a Client configured with the Config + GetClient() client.Interface + + // GetFieldIndexer returns a client.FieldIndexer configured with the Client + GetFieldIndexer() client.FieldIndexer +} + +var _ ControllerManager = &controllerManager{} + +type controllerManager struct { + // config is the rest.config used to talk to the apiserver. Required. + config *rest.Config + + // scheme is the scheme injected into Controllers, EventHandlers, Sources and Predicates. Defaults + // to scheme.scheme. + scheme *runtime.Scheme + + // controllers is the set of Controllers that the controllerManager injects deps into and Starts. + controllers []*controller + + // informers are injected into Controllers (,and transitively EventHandlers, Sources and Predicates). + informers informer.Informers + + // TODO(directxman12): Provide an escape hatch to get individual indexers + // client is the client injected into Controllers (and EventHandlers, Sources and Predicates). + client client.Interface + + // fieldIndexes knows how to add field indexes over the Informers used by this controller, + // which can later be consumed via field selectors from the injected client. + fieldIndexes client.FieldIndexer + + mu sync.Mutex + started bool + errChan chan error + stop <-chan struct{} +} + +func (cm *controllerManager) NewController(ca ControllerArgs, r reconcile.Reconcile) (Controller, error) { + cm.mu.Lock() + defer cm.mu.Unlock() + + if len(ca.Name) == 0 { + return nil, fmt.Errorf("Must specify name for Controller.") + } + + if ca.MaxConcurrentReconciles <= 0 { + ca.MaxConcurrentReconciles = 1 + } + + // Inject dependencies into Reconcile + cm.injectInto(r) + + // Create controller with dependencies set + c := &controller{ + reconcile: r, + informers: cm.informers, + config: cm.config, + scheme: cm.scheme, + client: cm.client, + queue: workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), ca.Name), + maxConcurrentReconciles: ca.MaxConcurrentReconciles, + name: ca.Name, + inject: cm.injectInto, + } + cm.controllers = append(cm.controllers, c) + + // If already started, start the controller + if cm.started { + go func() { + cm.errChan <- c.Start(cm.stop) + }() + } + return c, nil +} + +func (cm *controllerManager) injectInto(i interface{}) error { + if _, err := inject.InjectConfig(cm.config, i); err != nil { + return err + } + if _, err := inject.InjectClient(cm.client, i); err != nil { + return err + } + if _, err := inject.InjectScheme(cm.scheme, i); err != nil { + return err + } + if _, err := inject.InjectInformers(cm.informers, i); err != nil { + return err + } + return nil +} + +func (cm *controllerManager) GetConfig() *rest.Config { + return cm.config +} + +func (cm *controllerManager) GetClient() client.Interface { + return cm.client +} + +func (cm *controllerManager) GetScheme() *runtime.Scheme { + return cm.scheme +} + +func (cm *controllerManager) GetFieldIndexer() client.FieldIndexer { + return cm.fieldIndexes +} + +func (cm *controllerManager) Start(stop <-chan struct{}) error { + cm.mu.Lock() + defer cm.mu.Unlock() + + // Start the Informers. + cm.stop = stop + cm.informers.Start(stop) + + // Start the controllers after the promises + for _, c := range cm.controllers { + // Controllers block, but we want to return an error if any have an error starting. + // Write any Start errors to a channel so we can return them + go func() { + cm.errChan <- c.Start(stop) + }() + } + + cm.started = true + select { + case <-stop: + // We are done + return nil + case err := <-cm.errChan: + // Error starting a controller + return err + } +} + +// ControllerManagerArgs are the arguments for creating a new ControllerManager +type ControllerManagerArgs struct { + // Config is the config used to talk to an apiserver. Defaults to: + // 1. Config specified with the --config flag + // 2. Config specified with the KUBECONFIG environment variable + // 3. Incluster config (if running in a Pod) + // 4. $HOME/.kube/config + Config *rest.Config + + // Scheme is the scheme used to resolve runtime.Objects to GroupVersionKinds / Resources + // Defaults to the kubernetes/client-go scheme.Scheme + Scheme *runtime.Scheme +} + +// NewControllerManager returns a new fully initialized ControllerManager. +func NewControllerManager(args ControllerManagerArgs) (ControllerManager, error) { + cm := &controllerManager{config: args.Config, scheme: args.Scheme, errChan: make(chan error)} + + // Initialize a rest.config if none was specified + if cm.config == nil { + var err error + cm.config, err = config.GetConfig() + if err != nil { + return nil, err + } + } + + // Use the Kubernetes client-go scheme if none is specified + if cm.scheme == nil { + cm.scheme = scheme.Scheme + } + + spi := &informer.SelfPopulatingInformers{ + Config: cm.config, + Scheme: cm.scheme, + } + cm.informers = spi + cm.informers.InformerFor(&v1.Deployment{}) + + // Inject a Read / Write client into all controllers + // TODO(directxman12): Figure out how to allow users to request a client without requesting a watch + objCache := client.ObjectCacheFromInformers(spi.KnownInformersByType(), cm.scheme) + spi.Callbacks = append(spi.Callbacks, objCache) + + mapper, err := common.NewDiscoveryRESTMapper(cm.config) + if err != nil { + log.Error(err, "Failed to get API Group-Resources") + return nil, err + } + + cm.fieldIndexes = &client.InformerFieldIndexer{Informers: spi} + + // Inject the client after all the watches have been set + writeObj := &client.Client{Config: cm.config, Scheme: cm.scheme, Mapper: mapper} + cm.client = client.SplitReaderWriter{ReadInterface: objCache, WriteInterface: writeObj} + return cm, nil +} diff --git a/pkg/ctrl/controller_suite_test.go b/pkg/ctrl/controller_suite_test.go new file mode 100644 index 0000000000..6a22366f31 --- /dev/null +++ b/pkg/ctrl/controller_suite_test.go @@ -0,0 +1,59 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ctrl_test + +import ( + "testing" + "time" + + "github.com/kubernetes-sigs/kubebuilder/pkg/informer" + logf "github.com/kubernetes-sigs/kubebuilder/pkg/log" + "github.com/kubernetes-sigs/kubebuilder/pkg/test" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +func TestSource(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecsWithDefaultAndCustomReporters(t, "controller Suite", []Reporter{test.NewlineReporter{}}) +} + +var testenv *test.TestEnvironment +var cfg *rest.Config +var clientset *kubernetes.Clientset +var icache informer.Informers + +var _ = BeforeSuite(func() { + logf.SetLogger(logf.ZapLogger(true)) + + testenv = &test.TestEnvironment{} + + var err error + cfg, err = testenv.Start() + Expect(err).NotTo(HaveOccurred()) + + time.Sleep(1 * time.Second) + + clientset, err = kubernetes.NewForConfig(cfg) + Expect(err).NotTo(HaveOccurred()) +}) + +var _ = AfterSuite(func() { + testenv.Stop() +}) diff --git a/pkg/ctrl/controller_test.go b/pkg/ctrl/controller_test.go new file mode 100644 index 0000000000..58afb25466 --- /dev/null +++ b/pkg/ctrl/controller_test.go @@ -0,0 +1,25 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ctrl_test + +import ( + . "github.com/onsi/ginkgo" +) + +var _ = Describe("controller", func() { + +}) diff --git a/pkg/ctrl/doc.go b/pkg/ctrl/doc.go new file mode 100644 index 0000000000..220c9852c7 --- /dev/null +++ b/pkg/ctrl/doc.go @@ -0,0 +1,280 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* +Package ctrl provides libraries for building Controllers. Controllers implement Kubernetes APIs +and are central to building Operators, Workload APIs, Configuration APIs, Autoscalers, and more. + +Controllers + +Controllers are work queues that enqueue work in response to source.Source events (e.g. Pod Create, Update, Delete) +and trigger reconcile.reconcile functions when the work is dequeued. + +Unlike http handlers, Controllers DO NOT perform work directly in response to events, but instead enqueue +ReconcileRequests so the work is performed eventually. + +* Controllers run reconcile.reconcile functions against objects (provided as name / Namespace). + +* Controllers enqueue reconcile.ReconcileRequests in response events provided by source.Sources. + +reconcile + +reconcile.reconcile is a function that may be called at anytime with the name / Namespace of an +object. When called, it will ensure that the state of the system matches what is specified in the object at the +time reconcile is called. + +Example: reconcile is run against a ReplicationController object. The ReplicationController specifies 5 replicas. +3 Pods exist in the system. reconcile creates 2 more Pods and sets their OwnerReference to point at the +ReplicationController. + +* reconcile works on a single object type. - e.g. it will only reconcile ReplicaSets. + +* reconcile is triggered by a ReconcileRequest containing the name / Namespace of an object to reconcile. + +* reconcile does not care about the event contents or event type triggering the ReconcileRequest. +- e.g. it doesn't matter whether a ReplicaSet was created or updated, reconcile will check that the correct +Pods exist either way. + +* Users MUST implement reconcile themselves. + +Source + +resource.Source provides a stream of events. Events may be internal events from watching Kubernetes +APIs (e.g. Pod Create, Update, Delete), or may be synthetic Generic events triggered by cron or WebHooks +(e.g. through a Slackbot or GitHub callback). + +Example 1: source.KindSource uses the Kubernetes API Watch endpoint for a GroupVersionKind to provide +Create, Update, Delete events. + +Example 2: source.ChannelSource reads Generic events from a channel fed by a WebHook called from a Slackbot. + +* Source provides a stream of events for EventHandlers to handle. + +* Source may provide either events from Watches (e.g. object Create, Update, Delete) or Generic triggered +from another source (e.g. WebHook callback). + +* Users SHOULD use the provided Source implementations instead of implementing their own for nearly all cases. + +EventHandler + +eventhandler.EventHandler transforms and enqueues events from a source.Source into reconcile.ReconcileRequests. + +Example: a Pod Create event from a Source is provided to the eventhandler.EnqueueHandler, which enqueues a +ReconcileRequest containing the name / Namespace of the Pod. + +* EventHandler takes an event.Event and enqueues ReconcileRequests + +* EventHandlers MAY map an event for an object of one type to a ReconcileRequest for an object of another type. + +* EventHandlers MAY map an event for an object to multiple ReconcileRequests for different objects. + +* Users SHOULD use the provided EventHandler implementations instead of implementing their own for almost all cases. + +Predicate + +predicate.Predicate allows events to be filtered before they are given to EventHandlers. This allows common +filters to be reused and composed together with EventHandlers. + +* Predicate takes and event.Event and returns a bool (true to enqueue) + +* Predicates are optional + +* Users SHOULD use the provided Predicate implementations, but MAY implement their own Predicates as needed. + +PodController Diagram + +Source provides event: + +* &source.KindSource{"core", "v1", "Pod"} -> (Pod foo/bar Create Event) + +EventHandler enqueues ReconcileRequest: + +* &eventhandler.Enqueue{} -> (ReconcileRequest{"foo", "bar"}) + +Reconcile is called with the ReconcileRequest: + +* Reconcile(ReconcileRequest{"foo", "bar"}) + + +controllerManager + +controllerManager registers and starts Controllers. It initializes shared dependencies - such as clients, caches, +stop channels, etc and provides these to the Controllers that it manages. controllerManager should be used +anytime multiple Controllers exist within the same program. + +Usage + +The following example shows creating a new Controller program which Reconciles ReplicaSet objects in response +to Pod or ReplicaSet events. The Reconcile function simply adds a label to the ReplicaSet. + + import ( + "context" + "flag" + "log" + + "github.com/kubernetes-sigs/kubebuilder/pkg/client" + "github.com/kubernetes-sigs/kubebuilder/pkg/config" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/eventhandler" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/reconcile" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/source" + logf "github.com/kubernetes-sigs/kubebuilder/pkg/log" + "github.com/kubernetes-sigs/kubebuilder/pkg/signals" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + ) + + func main() { + flag.Parse() + logf.SetLogger(logf.ZapLogger(false)) + + // Setup a ControllerManager + manager, err := ctrl.NewControllerManager(ctrl.ControllerManagerArgs{Config: config.GetConfigOrDie()}) + if err != nil { + log.Fatal(err) + } + + // Setup a new controller to Reconcile ReplicaSets + c := manager.NewController( + ctrl.ControllerArgs{Name: "my-replicaset-controller", MaxConcurrentReconciles: 1}, + &ReconcileReplicaSet{client: manager.GetClient()}, + ) + + err = c.Watch( + // Watch ReplicaSets + &source.KindSource{Type: &appsv1.ReplicaSet{}}, + // Enqueue ReplicaSet object key + &eventhandler.EnqueueHandler{}) + if err != nil { + log.Fatal(err) + } + + err = c.Watch( + // Watch Pods + &source.KindSource{Type: &corev1.Pod{}}, + // Enqueue Owning ReplicaSet object key + &eventhandler.EnqueueOwnerHandler{OwnerType: &appsv1.ReplicaSet{}, IsController: true}) + if err != nil { + log.Fatal(err) + } + + log.Fatal(manager.Start(signals.SetupSignalHandler())) + } + + // ReconcileReplicaSet reconciles ReplicaSets + type ReconcileReplicaSet struct { + client client.Interface + } + + // Implement reconcile.reconcile so the controller can reconcile objects + var _ reconcile.Reconcile = &ReconcileReplicaSet{} + + func (r *ReconcileReplicaSet) Reconcile(request reconcile.ReconcileRequest) (reconcile.ReconcileResult, error) { + // Fetch the ReplicaSet from the cache + rs := &appsv1.ReplicaSet{} + err := r.client.Get(context.TODO(), request.NamespacedName, rs) + if errors.IsNotFound(err) { + log.Printf("Could not find ReplicaSet %v.\n", request) + return reconcile.ReconcileResult{}, nil + } + + if err != nil { + log.Printf("Could not fetch ReplicaSet %v for %+v\n", err, request) + return reconcile.ReconcileResult{}, err + } + + // Print the ReplicaSet + log.Printf("ReplicaSet Name %s Namespace %s, Pod Name: %s\n", + rs.Name, rs.Namespace, rs.Spec.Template.Spec.Containers[0].Name) + + // Set the label if it is missing + if rs.Labels == nil { + rs.Labels = map[string]string{} + } + if rs.Labels["hello"] == "world" { + return reconcile.ReconcileResult{}, nil + } + + // Update the ReplicaSet + rs.Labels["hello"] = "world" + err = r.client.Update(context.TODO(), rs) + if err != nil { + log.Printf("Could not write ReplicaSet %v\n", err) + return reconcile.ReconcileResult{}, err + } + + return reconcile.ReconcileResult{}, nil + } + +controller Example - Deployment + +1. Watch Deployment, ReplicaSet, Pod Sources + +1.1 Deployments -> eventhandler.EnqueueHandler - enqueue the Deployment object key. + +1.2 ReplicaSets (created by Deployments) -> eventhandler.EnqueueOwnerHandler - enqueue the Owning Deployment key. + +1.3 Pods (created by ReplicaSets) -> eventhandler.EnqueueOwnerHandler -> enqueue owning Deployment +key (transitive through ReplicaSet). + +2. reconcile Deployment + +2.1 Deployment object created -> Read Deployment, try to read ReplicaSet, see if is missing create ReplicaSet. + +2.2 reconcile triggered by creation of ReplicaSet and Pods -> Read Deployment and ReplicaSet, do nothing. + +Watching and EventHandling + +Controllers may Watch multiple Kinds of objects (e.g. Pods, ReplicaSets and Deployments), but they should +enqueue keys for only a single Type. When one Type of object must be be updated in response to changes +in another Type of object, an EnqueueMappedHandler may be used to reconcile the Type that is being +updated and watch the other Type for Events. e.g. Respond to a cluster resize +Event (add / delete Node) by re-reconciling all instances of another Type that cares about the cluster size. + +For example, a Deployment controller might use an EnqueueHandler and EnqueueOwnerHandler to: + +* Watch for Deployment Events - enqueue the key of the Deployment. + +* Watch for ReplicaSet Events - enqueue the key of the Deployment that created the ReplicaSet (owns directly) + +* Watch for Pod Events - enqueue the key of the Deployment that created the Pod (owns transitively through a ReplicaSet). + +Note: ReconcileRequests are deduplicated when they are enqueued. Many Pod Events for the same Deployment +may trigger only 1 reconcile invocation as each Event results in the Handler trying to enqueue +the same ReconcileRequest for the Deployment. + +controller Writing Tips + +reconcile Runtime Complexity: + +* It is better to write Controllers to perform an O(1) reconcile N times (e.g. on N different objects) instead of +performing an O(N) reconcile 1 time (e.g. on a single object which manages N other objects). + +* Example: If you need to update all Services in response to a Node being added - reconcile Services but Watch +Node events (transformed to Service object name / Namespaces) instead of Reconciling the Node and updating all +Services from a single reconcile. + +Event Multiplexing: + +* ReconcileRequests for the same name / Namespace are deduplicated when they are enqueued. This allows +for Controllers to gracefully handle event storms for a single object. Multiplexing multiple event Sources to +a single object type takes advantage of this. + +* Example: Pod events for a ReplicaSet are transformed to a ReplicaSet name / Namespace, so the ReplicaSet +will be Reconciled only 1 time for multiple Pods. +*/ +package ctrl diff --git a/pkg/ctrl/event/event.go b/pkg/ctrl/event/event.go new file mode 100644 index 0000000000..7005cb247a --- /dev/null +++ b/pkg/ctrl/event/event.go @@ -0,0 +1,61 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package event + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// Update is an event where a Kubernetes object was created. +type CreateEvent struct { + // Meta is the ObjectMeta of the Kubernetes Type that was created + Meta v1.Object + + Object runtime.Object +} + +// Update is an event where a Kubernetes object was updated. +type UpdateEvent struct { + // MetaOld is the ObjectMeta of the Kubernetes Type that was updated (before the update) + MetaOld v1.Object + + ObjectOld runtime.Object + + // MetaNew is the ObjectMeta of the Kubernetes Type that was updated (after the update) + MetaNew v1.Object + + ObjectNew runtime.Object +} + +// Update is an event where a Kubernetes object was deleted. +type DeleteEvent struct { + // Meta is the ObjectMeta of the Kubernetes Type that was deleted + Meta v1.Object + + Object runtime.Object + + DeleteStateUnknown bool +} + +// GenericEvent is an event where the operation type is unknown (e.g. polling or event originating outside the cluster). +type GenericEvent struct { + // Meta is the ObjectMeta of a Kubernetes Type this event is for + Meta v1.Object + + Object runtime.Object +} diff --git a/pkg/ctrl/event/event_suite_test.go b/pkg/ctrl/event/event_suite_test.go new file mode 100644 index 0000000000..be9d443dac --- /dev/null +++ b/pkg/ctrl/event/event_suite_test.go @@ -0,0 +1,35 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package event_test + +import ( + "testing" + + logf "github.com/kubernetes-sigs/kubebuilder/pkg/log" + "github.com/kubernetes-sigs/kubebuilder/pkg/test" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestEvent(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecsWithDefaultAndCustomReporters(t, "Event Suite", []Reporter{test.NewlineReporter{}}) +} + +var _ = BeforeSuite(func() { + logf.SetLogger(logf.ZapLogger(true)) +}) diff --git a/pkg/ctrl/event/event_test.go b/pkg/ctrl/event/event_test.go new file mode 100644 index 0000000000..c696dc7d3a --- /dev/null +++ b/pkg/ctrl/event/event_test.go @@ -0,0 +1,25 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package event_test + +import ( + . "github.com/onsi/ginkgo" +) + +var _ = Describe("Event", func() { + +}) diff --git a/pkg/ctrl/eventhandler/doc.go b/pkg/ctrl/eventhandler/doc.go new file mode 100644 index 0000000000..569c9ce7b3 --- /dev/null +++ b/pkg/ctrl/eventhandler/doc.go @@ -0,0 +1,30 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* +Package eventhandler defines EventHandlers that enqueue ReconcileRequests in response to Create, Update, Deletion Events +observed from Watching Kubernetes APIs. + +EventHandlers + +EnqueueHandler - Enqueues a ReconcileRequest containing the Name and Namespace of the object in the Event. + +EnqueueOwnerHandler - Enqueues a ReconcileRequest containing the Name and Namespace of the Owner of the object in the Event. + +EnqueueMappedHander - Enqueues ReconcileRequests resulting from a user provided transformation function run against the +object in the Event. +*/ +package eventhandler diff --git a/pkg/ctrl/eventhandler/enqueue.go b/pkg/ctrl/eventhandler/enqueue.go new file mode 100644 index 0000000000..df9ae35bb6 --- /dev/null +++ b/pkg/ctrl/eventhandler/enqueue.go @@ -0,0 +1,85 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package eventhandler + +import ( + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/event" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/reconcile" + logf "github.com/kubernetes-sigs/kubebuilder/pkg/log" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" +) + +var enqueueLog = logf.KBLog.WithName("eventhandler").WithName("EnqueueHandler") + +var _ EventHandler = &EnqueueHandler{} + +// EnqueueHandler enqueues a ReconcileRequest containing the Name and Namespace of the object for each event. +type EnqueueHandler struct{} + +func (e *EnqueueHandler) Create(q workqueue.RateLimitingInterface, evt event.CreateEvent) { + if evt.Meta == nil { + enqueueLog.Error(nil, "CreateEvent received with no metadata", "CreateEvent", evt) + return + } + q.AddRateLimited(reconcile.ReconcileRequest{types.NamespacedName{ + Name: evt.Meta.GetName(), + Namespace: evt.Meta.GetNamespace(), + }}) +} + +func (e *EnqueueHandler) Update(q workqueue.RateLimitingInterface, evt event.UpdateEvent) { + if evt.MetaOld != nil { + q.AddRateLimited(reconcile.ReconcileRequest{types.NamespacedName{ + Name: evt.MetaOld.GetName(), + Namespace: evt.MetaOld.GetNamespace(), + }}) + } else { + enqueueLog.Error(nil, "UpdateEvent received with no old metadata", "UpdateEvent", evt) + } + + if evt.MetaNew != nil { + q.AddRateLimited(reconcile.ReconcileRequest{types.NamespacedName{ + Name: evt.MetaNew.GetName(), + Namespace: evt.MetaNew.GetNamespace(), + }}) + } else { + enqueueLog.Error(nil, "UpdateEvent received with no new metadata", "UpdateEvent", evt) + } +} + +func (e *EnqueueHandler) Delete(q workqueue.RateLimitingInterface, evt event.DeleteEvent) { + if evt.Meta == nil { + enqueueLog.Error(nil, "DeleteEvent received with no metadata", "DeleteEvent", evt) + return + } + q.AddRateLimited(reconcile.ReconcileRequest{types.NamespacedName{ + Name: evt.Meta.GetName(), + Namespace: evt.Meta.GetNamespace(), + }}) +} + +func (e *EnqueueHandler) Generic(q workqueue.RateLimitingInterface, evt event.GenericEvent) { + if evt.Meta == nil { + enqueueLog.Error(nil, "GenericEvent received with no metadata", "GenericEvent", evt) + return + } + q.AddRateLimited(reconcile.ReconcileRequest{types.NamespacedName{ + Name: evt.Meta.GetName(), + Namespace: evt.Meta.GetNamespace(), + }}) +} diff --git a/pkg/ctrl/eventhandler/enqueue_mapped.go b/pkg/ctrl/eventhandler/enqueue_mapped.go new file mode 100644 index 0000000000..6c1597c445 --- /dev/null +++ b/pkg/ctrl/eventhandler/enqueue_mapped.go @@ -0,0 +1,83 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package eventhandler + +import ( + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/event" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/reconcile" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/util/workqueue" +) + +var _ EventHandler = &EnqueueMappedHandler{} + +// EnqueueMappedHandler enqueues ReconcileRequests by running a transformation function on each Event. +// +// For UpdateEvents which contain both a new and old object, the transformation function is run on both +// objects and both sets of ReconcileRequests are enqueue. +type EnqueueMappedHandler struct { + // Mapper transforms the argument into a slice of keys to be reconciled + ToRequests Mapper +} + +func (e *EnqueueMappedHandler) Create(q workqueue.RateLimitingInterface, evt event.CreateEvent) { + e.mapAndEnqueue(q, MapObject{Meta: evt.Meta, Object: evt.Object}) +} + +func (e *EnqueueMappedHandler) Update(q workqueue.RateLimitingInterface, evt event.UpdateEvent) { + e.mapAndEnqueue(q, MapObject{Meta: evt.MetaOld, Object: evt.ObjectOld}) + e.mapAndEnqueue(q, MapObject{Meta: evt.MetaNew, Object: evt.ObjectNew}) +} + +func (e *EnqueueMappedHandler) Delete(q workqueue.RateLimitingInterface, evt event.DeleteEvent) { + e.mapAndEnqueue(q, MapObject{Meta: evt.Meta, Object: evt.Object}) +} + +func (e *EnqueueMappedHandler) Generic(q workqueue.RateLimitingInterface, evt event.GenericEvent) { + e.mapAndEnqueue(q, MapObject{Meta: evt.Meta, Object: evt.Object}) +} + +func (e *EnqueueMappedHandler) mapAndEnqueue(q workqueue.RateLimitingInterface, object MapObject) { + for _, req := range e.ToRequests.Map(object) { + q.AddRateLimited(req) + } +} + +// Mapper maps an object to a collection of keys to be enqueued +type Mapper interface { + // Map maps an object + Map(MapObject) []reconcile.ReconcileRequest +} + +// MapObject contains information from an event to be transformed into a ReconcileRequest. +type MapObject struct { + // Meta is the meta data for an object from an event. + Meta metav1.Object + + // Object is the object from an event. + Object runtime.Object +} + +var _ Mapper = ToRequestsFunc(nil) + +// ToRequestsFunc implements Mapper using a function. +type ToRequestsFunc func(MapObject) []reconcile.ReconcileRequest + +func (m ToRequestsFunc) Map(i MapObject) []reconcile.ReconcileRequest { + return m(i) +} diff --git a/pkg/ctrl/eventhandler/enqueue_owner.go b/pkg/ctrl/eventhandler/enqueue_owner.go new file mode 100644 index 0000000000..c28c203cfe --- /dev/null +++ b/pkg/ctrl/eventhandler/enqueue_owner.go @@ -0,0 +1,168 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package eventhandler + +import ( + "fmt" + + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/event" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/inject" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/reconcile" + logf "github.com/kubernetes-sigs/kubebuilder/pkg/log" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" +) + +var _ EventHandler = &EnqueueOwnerHandler{} + +var log = logf.KBLog.WithName("eventhandler").WithName("EnqueueOwnerHandler") + +// EnqueueOwnerHandler enqueues ReconcileRequests for the Owners of an object. E.g. an object that created +// another object. +// +// If a ReplicaSet creates Pods, reconcile the ReplicaSet in response to events on Pods that it created using: +// +// - a KindSource with Type Pod. +// +// - a EnqueueOwnerHandler with OwnerType ReplicaSet. +type EnqueueOwnerHandler struct { + // OwnerType is the type of the Owner object to look for in OwnerReferences. Only Group and Kind are compared. + OwnerType runtime.Object + + // IsController if set will only look at the first OwnerReference with Controller: true. + IsController bool + + // groupKind is the cached Group and Kind from OwnerType + groupKind schema.GroupKind + + // kindOk is true if OwnerType was successfuly parsed + kindOk bool +} + +var _ inject.Scheme = &EnqueueOwnerHandler{} + +// InjectScheme is called by the Controller to provide a singleton scheme to the EnqueueOwnerHandler. +func (e *EnqueueOwnerHandler) InjectScheme(s *runtime.Scheme) error { + return e.parseOwnerTypeGroupKind(s) +} + +// Create implements EventHandler +func (e *EnqueueOwnerHandler) Create(q workqueue.RateLimitingInterface, evt event.CreateEvent) { + for _, req := range e.getOwnerReconcileRequest(evt.Meta) { + q.AddRateLimited(req) + } +} + +// Update implements EventHandler +func (e *EnqueueOwnerHandler) Update(q workqueue.RateLimitingInterface, evt event.UpdateEvent) { + for _, req := range e.getOwnerReconcileRequest(evt.MetaOld) { + q.AddRateLimited(req) + } + for _, req := range e.getOwnerReconcileRequest(evt.MetaNew) { + q.AddRateLimited(req) + } +} + +// Delete implements EventHandler +func (e *EnqueueOwnerHandler) Delete(q workqueue.RateLimitingInterface, evt event.DeleteEvent) { + for _, req := range e.getOwnerReconcileRequest(evt.Meta) { + q.AddRateLimited(req) + } +} + +// Generic implements EventHandler +func (e *EnqueueOwnerHandler) Generic(q workqueue.RateLimitingInterface, evt event.GenericEvent) { + for _, req := range e.getOwnerReconcileRequest(evt.Meta) { + q.AddRateLimited(req) + } +} + +// parseOwnerTypeGroupKind parses the OwnerType into a Group and Kind and caches the result. Returns false +// if the OwnerType could not be parsed using the scheme. +func (e *EnqueueOwnerHandler) parseOwnerTypeGroupKind(scheme *runtime.Scheme) error { + // Get the kinds of the type + kinds, _, err := scheme.ObjectKinds(e.OwnerType) + if err != nil { + log.Error(err, "Could not get ObjectKinds for OwnerType", "OwnerType", e.OwnerType) + return err + } + // Expect only 1 kind. If there is more than one kind this is probably an edge case such as ListOptions. + if len(kinds) != 1 { + err := fmt.Errorf("Expected exactly 1 kind for OwnerType") + log.Error(err, "", "OwnerType", e.OwnerType, "Kinds", kinds) + return err + + } + // Cache the Group and Kind for the OwnerType + e.groupKind = schema.GroupKind{Group: kinds[0].Group, Kind: kinds[0].Kind} + return nil +} + +// getOwnerReconcileRequest looks at object and returns a slice of reconcile.ReconcileRequest to reconcile +// owners of object that match e.OwnerType. +func (e *EnqueueOwnerHandler) getOwnerReconcileRequest(object metav1.Object) []reconcile.ReconcileRequest { + // Iterate through the OwnerReferences looking for a match on Group and Kind against what was requested + // by the user + var result []reconcile.ReconcileRequest + for _, ref := range e.getOwnersReferences(object) { + // Parse the Group out of the OwnerReference to compare it to what was parsed out of the requested OwnerType + refGV, err := schema.ParseGroupVersion(ref.APIVersion) + if err != nil { + log.Error(err, "Could not parse OwnerReference GroupVersion", + "OwnerReference", ref.APIVersion) + return nil + } + + // Compare the OwnerReference Group and Kind against the OwnerType Group and Kind specified by the user. + // If the two match, create a ReconcileRequest for the objected referred to by + // the OwnerReference. Use the Name from the OwnerReference and the Namespace from the + // object in the event. + if ref.Kind == e.groupKind.Kind && refGV.Group == e.groupKind.Group { + // Match found - add a ReconcileRequest for the object referred to in the OwnerReference + result = append(result, reconcile.ReconcileRequest{NamespacedName: types.NamespacedName{ + Namespace: object.GetNamespace(), + Name: ref.Name, + }}) + } + } + + // Return the matches + return result +} + +// getOwnersReferences returns the OwnerReferences for an object as specified by the EnqueueOwnerHandler +// - if IsController is true: only take the Controller OwnerReference (if found) +// - if IsController is false: take all OwnerReferences +func (e *EnqueueOwnerHandler) getOwnersReferences(object metav1.Object) []metav1.OwnerReference { + if object == nil { + return nil + } + + // If filtered to Controller use all the OwnerReferences + if !e.IsController { + return object.GetOwnerReferences() + } + // If filtered to a Controller, only take the Controller OwnerReference + if ownerRef := metav1.GetControllerOf(object); ownerRef != nil { + return []metav1.OwnerReference{*ownerRef} + } + // No Controller OwnerReference found + return nil +} diff --git a/pkg/ctrl/eventhandler/eventhandler.go b/pkg/ctrl/eventhandler/eventhandler.go new file mode 100644 index 0000000000..f87d2a0fe6 --- /dev/null +++ b/pkg/ctrl/eventhandler/eventhandler.go @@ -0,0 +1,100 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package eventhandler + +import ( + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/event" + "k8s.io/client-go/util/workqueue" +) + +// EventHandler enqueues ReconcileRequests in response to events (e.g. Pod Create). EventHandlers map an Event +// for one object to trigger Reconciles for either the same object or different objects - e.g. if there is an +// Event for object with type Foo (using source.KindSource) then reconcile one or more object(s) with type Bar. +// +// Identical ReconcileRequests will be batched together through the queuing mechanism before reconcile is called. +// +// * Use EnqueueHandler to reconcile the object the event is for +// - do this for events for the type the Controller Reconciles. (e.g. Deployment for a Deployment Controller) +// +// * Use EnqueueOwnerHandler to reconcile the owner of the object the event is for +// - do this for events for the types the Controller creates. (e.g. ReplicaSets created by a Deployment Controller) +// +// * Use EnqueueMappendHandler to transform an event for an object to a reconcile of an object +// of a different type - do this for events for types the Controller may be interested in, but doesn't create. +// (e.g. If Foo responds to cluster size events, map Node events to Foo objects.) +// +// Unless you are implementing your own EventHandler, you can ignore the functions on the EventHandler interface. +// Most users shouldn't need to implement their own EventHandler. +type EventHandler interface { + // Create is called in response to an create event - e.g. Pod Creation. + Create(workqueue.RateLimitingInterface, event.CreateEvent) + + // Update is called in response to an update event - e.g. Pod Updated. + Update(workqueue.RateLimitingInterface, event.UpdateEvent) + + // Delete is called in response to a delete event - e.g. Pod Deleted. + Delete(workqueue.RateLimitingInterface, event.DeleteEvent) + + // Generic is called in response to an event of an unknown type or a synthetic event triggered as a cron or + // external trigger request - e.g. reconcile Autoscaling, or a Webhook. + Generic(workqueue.RateLimitingInterface, event.GenericEvent) +} + +var _ EventHandler = EventHandlerFuncs{} + +// EventHandlerFuncs allows specifying a subset of EventHandler functions are fields. +type EventHandlerFuncs struct { + // Create is called in response to an add event. Defaults to no-op. + // RateLimitingInterface is used to enqueue reconcile.ReconcileRequests. + CreateFunc func(workqueue.RateLimitingInterface, event.CreateEvent) + + // Update is called in response to an update event. Defaults to no-op. + // RateLimitingInterface is used to enqueue reconcile.ReconcileRequests. + UpdateFunc func(workqueue.RateLimitingInterface, event.UpdateEvent) + + // Delete is called in response to a delete event. Defaults to no-op. + // RateLimitingInterface is used to enqueue reconcile.ReconcileRequests. + DeleteFunc func(workqueue.RateLimitingInterface, event.DeleteEvent) + + // GenericFunc is called in response to a generic event. Defaults to no-op. + // RateLimitingInterface is used to enqueue reconcile.ReconcileRequests. + GenericFunc func(workqueue.RateLimitingInterface, event.GenericEvent) +} + +func (h EventHandlerFuncs) Create(q workqueue.RateLimitingInterface, e event.CreateEvent) { + if h.CreateFunc != nil { + h.CreateFunc(q, e) + } +} + +func (h EventHandlerFuncs) Delete(q workqueue.RateLimitingInterface, e event.DeleteEvent) { + if h.DeleteFunc != nil { + h.DeleteFunc(q, e) + } +} + +func (h EventHandlerFuncs) Update(q workqueue.RateLimitingInterface, e event.UpdateEvent) { + if h.UpdateFunc != nil { + h.UpdateFunc(q, e) + } +} + +func (h EventHandlerFuncs) Generic(q workqueue.RateLimitingInterface, e event.GenericEvent) { + if h.GenericFunc != nil { + h.GenericFunc(q, e) + } +} diff --git a/pkg/ctrl/eventhandler/eventhandler_suite_test.go b/pkg/ctrl/eventhandler/eventhandler_suite_test.go new file mode 100644 index 0000000000..61d88790d8 --- /dev/null +++ b/pkg/ctrl/eventhandler/eventhandler_suite_test.go @@ -0,0 +1,35 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package eventhandler_test + +import ( + "testing" + + logf "github.com/kubernetes-sigs/kubebuilder/pkg/log" + "github.com/kubernetes-sigs/kubebuilder/pkg/test" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestEventhandler(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecsWithDefaultAndCustomReporters(t, "Eventhandler Suite", []Reporter{test.NewlineReporter{}}) +} + +var _ = BeforeSuite(func() { + logf.SetLogger(logf.ZapLogger(true)) +}) diff --git a/pkg/ctrl/eventhandler/eventhandler_test.go b/pkg/ctrl/eventhandler/eventhandler_test.go new file mode 100644 index 0000000000..09caefd560 --- /dev/null +++ b/pkg/ctrl/eventhandler/eventhandler_test.go @@ -0,0 +1,903 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package eventhandler_test + +import ( + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/event" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/eventhandler" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/reconcile" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/testing" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/util/workqueue" +) + +var _ = Describe("Eventhandler", func() { + var q workqueue.RateLimitingInterface + var instance eventhandler.EnqueueHandler + var pod *corev1.Pod + t := true + BeforeEach(func() { + q = testing.Queue{workqueue.New()} + instance = eventhandler.EnqueueHandler{} + pod = &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Namespace: "biz", Name: "baz"}, + } + }) + + Describe("EnqueueHandler", func() { + It("should enqueue a ReconcileRequest with the Name / Namespace of the object in the CreateEvent.", func(done Done) { + evt := event.CreateEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Create(q, evt) + Expect(q.Len()).To(Equal(1)) + + i, _ := q.Get() + Expect(i).NotTo(BeNil()) + req, ok := i.(reconcile.ReconcileRequest) + Expect(ok).To(BeTrue()) + Expect(req.NamespacedName).To(Equal(types.NamespacedName{Namespace: "biz", Name: "baz"})) + + close(done) + }) + + It("should enqueue a ReconcileRequest with the Name / Namespace of the object in the DeleteEvent.", func(done Done) { + evt := event.DeleteEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Delete(q, evt) + Expect(q.Len()).To(Equal(1)) + + i, _ := q.Get() + Expect(i).NotTo(BeNil()) + req, ok := i.(reconcile.ReconcileRequest) + Expect(ok).To(BeTrue()) + Expect(req.NamespacedName).To(Equal(types.NamespacedName{Namespace: "biz", Name: "baz"})) + + close(done) + }) + + It("should enqueue a ReconcileRequest with the Name / Namespace of both objects in the UpdateEvent.", + func(done Done) { + newPod := pod.DeepCopy() + newPod.Name = "baz2" + newPod.Namespace = "biz2" + + evt := event.UpdateEvent{ + ObjectOld: pod, + MetaOld: pod.GetObjectMeta(), + ObjectNew: newPod, + MetaNew: newPod.GetObjectMeta(), + } + instance.Update(q, evt) + Expect(q.Len()).To(Equal(2)) + + i, _ := q.Get() + Expect(i).NotTo(BeNil()) + req, ok := i.(reconcile.ReconcileRequest) + Expect(ok).To(BeTrue()) + Expect(req.NamespacedName).To(Equal(types.NamespacedName{Namespace: "biz", Name: "baz"})) + + i, _ = q.Get() + Expect(i).NotTo(BeNil()) + req, ok = i.(reconcile.ReconcileRequest) + Expect(ok).To(BeTrue()) + Expect(req.NamespacedName).To(Equal(types.NamespacedName{Namespace: "biz2", Name: "baz2"})) + + close(done) + }) + + It("should enqueue a ReconcileRequest with the Name / Namespace of the object in the GenericEvent.", func(done Done) { + evt := event.GenericEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Generic(q, evt) + Expect(q.Len()).To(Equal(1)) + i, _ := q.Get() + Expect(i).NotTo(BeNil()) + req, ok := i.(reconcile.ReconcileRequest) + Expect(ok).To(BeTrue()) + Expect(req.NamespacedName).To(Equal(types.NamespacedName{Namespace: "biz", Name: "baz"})) + + close(done) + }) + + Context("for a runtime.Object without Metadata", func() { + It("should do nothing if the Metadata is missing for a CreateEvent.", func(done Done) { + evt := event.CreateEvent{ + Object: pod, + } + instance.Create(q, evt) + Expect(q.Len()).To(Equal(0)) + close(done) + }) + + It("should do nothing if the Metadata is missing for a UpdateEvent.", func(done Done) { + newPod := pod.DeepCopy() + newPod.Name = "baz2" + newPod.Namespace = "biz2" + + evt := event.UpdateEvent{ + ObjectNew: newPod, + MetaNew: newPod.GetObjectMeta(), + ObjectOld: pod, + } + instance.Update(q, evt) + Expect(q.Len()).To(Equal(1)) + i, _ := q.Get() + Expect(i).NotTo(BeNil()) + req, ok := i.(reconcile.ReconcileRequest) + Expect(ok).To(BeTrue()) + Expect(req.NamespacedName).To(Equal(types.NamespacedName{Namespace: "biz2", Name: "baz2"})) + + evt.MetaNew = nil + evt.MetaOld = pod.GetObjectMeta() + instance.Update(q, evt) + Expect(q.Len()).To(Equal(1)) + i, _ = q.Get() + Expect(i).NotTo(BeNil()) + req, ok = i.(reconcile.ReconcileRequest) + Expect(ok).To(BeTrue()) + Expect(req.NamespacedName).To(Equal(types.NamespacedName{Namespace: "biz", Name: "baz"})) + + close(done) + }) + + It("should do nothing if the Metadata is missing for a DeleteEvent.", func(done Done) { + evt := event.DeleteEvent{ + Object: pod, + } + instance.Delete(q, evt) + Expect(q.Len()).To(Equal(0)) + close(done) + }) + + It("should do nothing if the Metadata is missing for a GenericEvent.", func(done Done) { + evt := event.GenericEvent{ + Object: pod, + } + instance.Generic(q, evt) + Expect(q.Len()).To(Equal(0)) + close(done) + }) + }) + }) + + Describe("EnqueueMappedHandler", func() { + It("should enqueue a ReconcileRequest with the function applied to the CreateEvent.", func() { + req := []reconcile.ReconcileRequest{} + instance := eventhandler.EnqueueMappedHandler{ + ToRequests: eventhandler.ToRequestsFunc(func(a eventhandler.MapObject) []reconcile.ReconcileRequest { + defer GinkgoRecover() + Expect(a.Meta).To(Equal(pod.GetObjectMeta())) + Expect(a.Object).To(Equal(pod)) + req = []reconcile.ReconcileRequest{ + { + types.NamespacedName{"foo", "bar"}, + }, + { + types.NamespacedName{"biz", "baz"}, + }, + } + return req + }), + } + + evt := event.CreateEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Create(q, evt) + Expect(q.Len()).To(Equal(2)) + + i, _ := q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{"foo", "bar"}})) + + i, _ = q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{"biz", "baz"}})) + }) + + It("should enqueue a ReconcileRequest with the function applied to the DeleteEvent.", func() { + req := []reconcile.ReconcileRequest{} + instance := eventhandler.EnqueueMappedHandler{ + ToRequests: eventhandler.ToRequestsFunc(func(a eventhandler.MapObject) []reconcile.ReconcileRequest { + defer GinkgoRecover() + Expect(a.Meta).To(Equal(pod.GetObjectMeta())) + Expect(a.Object).To(Equal(pod)) + req = []reconcile.ReconcileRequest{ + { + types.NamespacedName{"foo", "bar"}, + }, + { + types.NamespacedName{"biz", "baz"}, + }, + } + return req + }), + } + + evt := event.DeleteEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Delete(q, evt) + Expect(q.Len()).To(Equal(2)) + + i, _ := q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{"foo", "bar"}})) + + i, _ = q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{"biz", "baz"}})) + }) + + It("should enqueue a ReconcileRequest with the function applied to both objects in the UpdateEvent.", + func() { + newPod := pod.DeepCopy() + newPod.Name = pod.Name + "2" + newPod.Namespace = pod.Namespace + "2" + + req := []reconcile.ReconcileRequest{} + instance := eventhandler.EnqueueMappedHandler{ + ToRequests: eventhandler.ToRequestsFunc(func(a eventhandler.MapObject) []reconcile.ReconcileRequest { + defer GinkgoRecover() + req = []reconcile.ReconcileRequest{ + { + types.NamespacedName{"foo", a.Meta.GetName() + "-bar"}, + }, + { + types.NamespacedName{"biz", a.Meta.GetName() + "-baz"}, + }, + } + return req + }), + } + + evt := event.UpdateEvent{ + ObjectOld: pod, + MetaOld: pod.GetObjectMeta(), + ObjectNew: newPod, + MetaNew: newPod.GetObjectMeta(), + } + instance.Update(q, evt) + Expect(q.Len()).To(Equal(4)) + + i, _ := q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{"foo", "baz-bar"}})) + + i, _ = q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{"biz", "baz-baz"}})) + + i, _ = q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{"foo", "baz2-bar"}})) + + i, _ = q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{"biz", "baz2-baz"}})) + }) + + It("should enqueue a ReconcileRequest with the function applied to the GenericEvent.", func() { + req := []reconcile.ReconcileRequest{} + instance := eventhandler.EnqueueMappedHandler{ + ToRequests: eventhandler.ToRequestsFunc(func(a eventhandler.MapObject) []reconcile.ReconcileRequest { + defer GinkgoRecover() + Expect(a.Meta).To(Equal(pod.GetObjectMeta())) + Expect(a.Object).To(Equal(pod)) + req = []reconcile.ReconcileRequest{ + { + types.NamespacedName{"foo", "bar"}, + }, + { + types.NamespacedName{"biz", "baz"}, + }, + } + return req + }), + } + + evt := event.GenericEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Generic(q, evt) + Expect(q.Len()).To(Equal(2)) + + i, _ := q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{"foo", "bar"}})) + + i, _ = q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{"biz", "baz"}})) + }) + }) + + Describe("EnqueueOwnerHandler", func() { + It("should enqueue a ReconcileRequest with the Owner of the object in the CreateEvent.", func() { + instance := eventhandler.EnqueueOwnerHandler{ + OwnerType: &appsv1.ReplicaSet{}, + } + instance.InjectScheme(scheme.Scheme) + + pod.OwnerReferences = []metav1.OwnerReference{ + { + Name: "foo-parent", + Kind: "ReplicaSet", + APIVersion: "apps/v1", + }, + } + evt := event.CreateEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Create(q, evt) + Expect(q.Len()).To(Equal(1)) + + i, _ := q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{pod.GetNamespace(), "foo-parent"}})) + }) + + It("should enqueue a ReconcileRequest with the Owner of the object in the DeleteEvent.", func() { + instance := eventhandler.EnqueueOwnerHandler{ + OwnerType: &appsv1.ReplicaSet{}, + } + instance.InjectScheme(scheme.Scheme) + + pod.OwnerReferences = []metav1.OwnerReference{ + { + Name: "foo-parent", + Kind: "ReplicaSet", + APIVersion: "apps/v1", + }, + } + evt := event.DeleteEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Delete(q, evt) + Expect(q.Len()).To(Equal(1)) + + i, _ := q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{pod.GetNamespace(), "foo-parent"}})) + }) + + It("should enqueue a ReconcileRequest with the Owners of both objects in the UpdateEvent.", func() { + newPod := pod.DeepCopy() + newPod.Name = pod.Name + "2" + newPod.Namespace = pod.Namespace + "2" + + instance := eventhandler.EnqueueOwnerHandler{ + OwnerType: &appsv1.ReplicaSet{}, + } + instance.InjectScheme(scheme.Scheme) + + pod.OwnerReferences = []metav1.OwnerReference{ + { + Name: "foo1-parent", + Kind: "ReplicaSet", + APIVersion: "apps/v1", + }, + } + newPod.OwnerReferences = []metav1.OwnerReference{ + { + Name: "foo2-parent", + Kind: "ReplicaSet", + APIVersion: "apps/v1", + }, + } + evt := event.UpdateEvent{ + ObjectOld: pod, + MetaOld: pod.GetObjectMeta(), + ObjectNew: newPod, + MetaNew: newPod.GetObjectMeta(), + } + instance.Update(q, evt) + Expect(q.Len()).To(Equal(2)) + + i, _ := q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{pod.GetNamespace(), "foo1-parent"}})) + + i, _ = q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{newPod.GetNamespace(), "foo2-parent"}})) + }) + + It("should enqueue a ReconcileRequest with the Owner of the object in the GenericEvent.", func() { + instance := eventhandler.EnqueueOwnerHandler{ + OwnerType: &appsv1.ReplicaSet{}, + } + instance.InjectScheme(scheme.Scheme) + + pod.OwnerReferences = []metav1.OwnerReference{ + { + Name: "foo-parent", + Kind: "ReplicaSet", + APIVersion: "apps/v1", + }, + } + evt := event.GenericEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Generic(q, evt) + Expect(q.Len()).To(Equal(1)) + + i, _ := q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{pod.GetNamespace(), "foo-parent"}})) + }) + + It("should not enqueue a ReconcileRequest if there are no owners matching Group and Kind.", func() { + instance := eventhandler.EnqueueOwnerHandler{ + OwnerType: &appsv1.ReplicaSet{}, + IsController: t, + } + instance.InjectScheme(scheme.Scheme) + pod.OwnerReferences = []metav1.OwnerReference{ + { // Wrong group + Name: "foo1-parent", + Kind: "ReplicaSet", + APIVersion: "extensions/v1", + }, + { // Wrong kind + Name: "foo2-parent", + Kind: "Deployment", + APIVersion: "apps/v1", + }, + } + evt := event.CreateEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Create(q, evt) + Expect(q.Len()).To(Equal(0)) + }) + + It("should enqueue a ReconcileRequest if there are owners matching Group "+ + "and Kind with a different version.", func() { + instance := eventhandler.EnqueueOwnerHandler{ + OwnerType: &appsv1.ReplicaSet{}, + } + instance.InjectScheme(scheme.Scheme) + pod.OwnerReferences = []metav1.OwnerReference{ + { + Name: "foo-parent", + Kind: "ReplicaSet", + APIVersion: "apps/v2", + }, + } + evt := event.CreateEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Create(q, evt) + Expect(q.Len()).To(Equal(1)) + + i, _ := q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{pod.GetNamespace(), "foo-parent"}})) + }) + + It("should not enqueue a ReconcileRequest if there are no owners.", func() { + instance := eventhandler.EnqueueOwnerHandler{ + OwnerType: &appsv1.ReplicaSet{}, + } + instance.InjectScheme(scheme.Scheme) + evt := event.CreateEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Create(q, evt) + Expect(q.Len()).To(Equal(0)) + }) + + Context("with the Controller field set to true", func() { + It("should enqueue ReconcileRequests for only the first the Controller if there are "+ + "multiple Controller owners.", func() { + instance := eventhandler.EnqueueOwnerHandler{ + OwnerType: &appsv1.ReplicaSet{}, + IsController: t, + } + instance.InjectScheme(scheme.Scheme) + pod.OwnerReferences = []metav1.OwnerReference{ + { + Name: "foo1-parent", + Kind: "ReplicaSet", + APIVersion: "apps/v1", + }, + { + Name: "foo2-parent", + Kind: "ReplicaSet", + APIVersion: "apps/v1", + Controller: &t, + }, + { + Name: "foo3-parent", + Kind: "ReplicaSet", + APIVersion: "apps/v1", + }, + { + Name: "foo4-parent", + Kind: "ReplicaSet", + APIVersion: "apps/v1", + Controller: &t, + }, + { + Name: "foo5-parent", + Kind: "ReplicaSet", + APIVersion: "apps/v1", + }, + } + evt := event.CreateEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Create(q, evt) + Expect(q.Len()).To(Equal(1)) + i, _ := q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{pod.GetNamespace(), "foo2-parent"}})) + }) + + It("should not enqueue ReconcileRequests if there are no Controller owners.", func() { + instance := eventhandler.EnqueueOwnerHandler{ + OwnerType: &appsv1.ReplicaSet{}, + IsController: t, + } + instance.InjectScheme(scheme.Scheme) + pod.OwnerReferences = []metav1.OwnerReference{ + { + Name: "foo1-parent", + Kind: "ReplicaSet", + APIVersion: "apps/v1", + }, + { + Name: "foo2-parent", + Kind: "ReplicaSet", + APIVersion: "apps/v1", + }, + { + Name: "foo3-parent", + Kind: "ReplicaSet", + APIVersion: "apps/v1", + }, + } + evt := event.CreateEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Create(q, evt) + Expect(q.Len()).To(Equal(0)) + }) + + It("should not enqueue ReconcileRequests if there are no owners.", func() { + instance := eventhandler.EnqueueOwnerHandler{ + OwnerType: &appsv1.ReplicaSet{}, + IsController: t, + } + instance.InjectScheme(scheme.Scheme) + evt := event.CreateEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Create(q, evt) + Expect(q.Len()).To(Equal(0)) + }) + }) + + Context("with the Controller field set to false", func() { + It("should enqueue a ReconcileRequests for all owners.", func() { + instance := eventhandler.EnqueueOwnerHandler{ + OwnerType: &appsv1.ReplicaSet{}, + } + instance.InjectScheme(scheme.Scheme) + pod.OwnerReferences = []metav1.OwnerReference{ + { + Name: "foo1-parent", + Kind: "ReplicaSet", + APIVersion: "apps/v1", + }, + { + Name: "foo2-parent", + Kind: "ReplicaSet", + APIVersion: "apps/v1", + }, + { + Name: "foo3-parent", + Kind: "ReplicaSet", + APIVersion: "apps/v1", + }, + } + evt := event.CreateEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Create(q, evt) + Expect(q.Len()).To(Equal(3)) + + i, _ := q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{pod.GetNamespace(), "foo1-parent"}})) + i, _ = q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{pod.GetNamespace(), "foo2-parent"}})) + i, _ = q.Get() + Expect(i).To(Equal(reconcile.ReconcileRequest{ + types.NamespacedName{pod.GetNamespace(), "foo3-parent"}})) + }) + }) + + Context("with a nil metadata object", func() { + It("should do nothing.", func() { + instance := eventhandler.EnqueueOwnerHandler{ + OwnerType: &appsv1.ReplicaSet{}, + } + instance.InjectScheme(scheme.Scheme) + pod.OwnerReferences = []metav1.OwnerReference{ + { + Name: "foo1-parent", + Kind: "ReplicaSet", + APIVersion: "apps/v1", + }, + } + evt := event.CreateEvent{ + Object: pod, + } + instance.Create(q, evt) + Expect(q.Len()).To(Equal(0)) + }) + }) + + Context("with a multiple matching kinds", func() { + It("should do nothing.", func() { + instance := eventhandler.EnqueueOwnerHandler{ + OwnerType: &metav1.ListOptions{}, + } + instance.InjectScheme(scheme.Scheme) + pod.OwnerReferences = []metav1.OwnerReference{ + { + Name: "foo1-parent", + Kind: "ListOptions", + APIVersion: "meta/v1", + }, + } + evt := event.CreateEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Create(q, evt) + Expect(q.Len()).To(Equal(0)) + }) + }) + Context("with an OwnerType that cannot be resolved", func() { + It("should do nothing.", func() { + instance := eventhandler.EnqueueOwnerHandler{ + OwnerType: &testing.ErrorType{}, + } + instance.InjectScheme(scheme.Scheme) + pod.OwnerReferences = []metav1.OwnerReference{ + { + Name: "foo1-parent", + Kind: "ListOptions", + APIVersion: "meta/v1", + }, + } + evt := event.CreateEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Create(q, evt) + Expect(q.Len()).To(Equal(0)) + }) + }) + + Context("with a nil OwnerType", func() { + It("should do nothing.", func() { + instance := eventhandler.EnqueueOwnerHandler{} + instance.InjectScheme(scheme.Scheme) + pod.OwnerReferences = []metav1.OwnerReference{ + { + Name: "foo1-parent", + Kind: "OwnerType", + APIVersion: "meta/v1", + }, + } + evt := event.CreateEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Create(q, evt) + Expect(q.Len()).To(Equal(0)) + }) + }) + + Context("with an invalid APIVersion in the OwnerReference", func() { + It("should do nothing.", func() { + instance := eventhandler.EnqueueOwnerHandler{ + OwnerType: &appsv1.ReplicaSet{}, + } + instance.InjectScheme(scheme.Scheme) + pod.OwnerReferences = []metav1.OwnerReference{ + { + Name: "foo1-parent", + Kind: "ReplicaSet", + APIVersion: "apps/v1/fail", + }, + } + evt := event.CreateEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Create(q, evt) + Expect(q.Len()).To(Equal(0)) + }) + }) + }) + + Describe("EventHandlerFuncs", func() { + failingFuncs := eventhandler.EventHandlerFuncs{ + CreateFunc: func(workqueue.RateLimitingInterface, event.CreateEvent) { + defer GinkgoRecover() + Fail("Did not expect CreateEvent to be called.") + }, + DeleteFunc: func(q workqueue.RateLimitingInterface, e event.DeleteEvent) { + defer GinkgoRecover() + Fail("Did not expect DeleteEvent to be called.") + }, + UpdateFunc: func(workqueue.RateLimitingInterface, event.UpdateEvent) { + defer GinkgoRecover() + Fail("Did not expect UpdateEvent to be called.") + }, + GenericFunc: func(workqueue.RateLimitingInterface, event.GenericEvent) { + defer GinkgoRecover() + Fail("Did not expect GenericEvent to be called.") + }, + } + + It("should call CreateFunc for a CreateEvent if provided.", func(done Done) { + instance := failingFuncs + evt := event.CreateEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.CreateFunc = func(q2 workqueue.RateLimitingInterface, evt2 event.CreateEvent) { + defer GinkgoRecover() + Expect(q2).To(Equal(q)) + Expect(evt2).To(Equal(evt)) + } + instance.Create(q, evt) + close(done) + }) + + It("should NOT call CreateFunc for a CreateEvent if NOT provided.", func(done Done) { + instance := failingFuncs + instance.CreateFunc = nil + evt := event.CreateEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Create(q, evt) + close(done) + }) + + It("should call UpdateFunc for an UpdateEvent if provided.", func(done Done) { + newPod := pod.DeepCopy() + newPod.Name = pod.Name + "2" + newPod.Namespace = pod.Namespace + "2" + evt := event.UpdateEvent{ + ObjectOld: pod, + MetaOld: pod.GetObjectMeta(), + ObjectNew: newPod, + MetaNew: newPod.GetObjectMeta(), + } + + instance := failingFuncs + instance.UpdateFunc = func(q2 workqueue.RateLimitingInterface, evt2 event.UpdateEvent) { + defer GinkgoRecover() + Expect(q2).To(Equal(q)) + Expect(evt2).To(Equal(evt)) + } + + instance.Update(q, evt) + close(done) + }) + + It("should NOT call UpdateFunc for an UpdateEvent if NOT provided.", func(done Done) { + newPod := pod.DeepCopy() + newPod.Name = pod.Name + "2" + newPod.Namespace = pod.Namespace + "2" + evt := event.UpdateEvent{ + ObjectOld: pod, + MetaOld: pod.GetObjectMeta(), + ObjectNew: newPod, + MetaNew: newPod.GetObjectMeta(), + } + instance.Update(q, evt) + close(done) + }) + + It("should call DeleteFunc for a DeleteEvent if provided.", func(done Done) { + instance := failingFuncs + evt := event.DeleteEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.DeleteFunc = func(q2 workqueue.RateLimitingInterface, evt2 event.DeleteEvent) { + defer GinkgoRecover() + Expect(q2).To(Equal(q)) + Expect(evt2).To(Equal(evt)) + } + instance.Delete(q, evt) + close(done) + }) + + It("should NOT call DeleteFunc for a DeleteEvent if NOT provided.", func(done Done) { + instance := failingFuncs + instance.DeleteFunc = nil + evt := event.DeleteEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Delete(q, evt) + close(done) + }) + + It("should call GenericFunc for a GenericEvent if provided.", func(done Done) { + instance := failingFuncs + evt := event.GenericEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.GenericFunc = func(q2 workqueue.RateLimitingInterface, evt2 event.GenericEvent) { + defer GinkgoRecover() + Expect(q2).To(Equal(q)) + Expect(evt2).To(Equal(evt)) + } + instance.Generic(q, evt) + close(done) + }) + + It("should NOT call GenericFunc for a GenericEvent if NOT provided.", func(done Done) { + instance := failingFuncs + instance.GenericFunc = nil + evt := event.GenericEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + instance.Generic(q, evt) + close(done) + }) + }) +}) diff --git a/pkg/ctrl/eventhandler/example_test.go b/pkg/ctrl/eventhandler/example_test.go new file mode 100644 index 0000000000..1721bb86d3 --- /dev/null +++ b/pkg/ctrl/eventhandler/example_test.go @@ -0,0 +1,110 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package eventhandler_test + +import ( + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/event" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/eventhandler" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/reconcile" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/source" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" +) + +var controller ctrl.Controller + +// This example watches Pods and enqueues ReconcileRequests with the Name and Namespace of the Pod from +// the Event (i.e. change caused by a Create, Update, Delete). +func ExampleEnqueueHandler() { + // controller is a ctrl.controller + controller.Watch( + &source.KindSource{Type: &corev1.Pod{}}, + &eventhandler.EnqueueHandler{}, + ) +} + +// This example watches ReplicaSets and enqueues a ReconcileRequest containing the Name and Namespace of the +// owning (direct) Deployment responsible for the creation of the ReplicaSet. +func ExampleEnqueueOwnerHandler_1() { + // controller is a ctrl.controller + controller.Watch( + &source.KindSource{Type: &appsv1.ReplicaSet{}}, + &eventhandler.EnqueueOwnerHandler{ + OwnerType: &appsv1.Deployment{}, + IsController: true, + }, + ) +} + +// This example watches Deployments and enqueues a ReconcileRequest contain the Name and Namespace of different +// objects (of Type: MyKind) using a mapping function defined by the user. +func ExampleEnqueueMappedHandler() { + // controller is a ctrl.controller + controller.Watch( + &source.KindSource{Type: &appsv1.Deployment{}}, + &eventhandler.EnqueueMappedHandler{ + ToRequests: eventhandler.ToRequestsFunc(func(a eventhandler.MapObject) []reconcile.ReconcileRequest { + return []reconcile.ReconcileRequest{ + {NamespacedName: types.NamespacedName{ + Name: a.Meta.GetName() + "-1", + Namespace: a.Meta.GetNamespace(), + }}, + {NamespacedName: types.NamespacedName{ + Name: a.Meta.GetName() + "-2", + Namespace: a.Meta.GetNamespace(), + }}, + } + }), + }) +} + +// This example implements eventhandler.EnqueueHandler. +func ExampleEventHandlerFunc() { + // controller is a ctrl.controller + controller.Watch( + &source.KindSource{Type: &corev1.Pod{}}, + eventhandler.EventHandlerFuncs{ + CreateFunc: func(q workqueue.RateLimitingInterface, e event.CreateEvent) { + q.Add(reconcile.ReconcileRequest{NamespacedName: types.NamespacedName{ + Name: e.Meta.GetName(), + Namespace: e.Meta.GetNamespace(), + }}) + }, + UpdateFunc: func(q workqueue.RateLimitingInterface, e event.UpdateEvent) { + q.Add(reconcile.ReconcileRequest{NamespacedName: types.NamespacedName{ + Name: e.MetaNew.GetName(), + Namespace: e.MetaNew.GetNamespace(), + }}) + }, + DeleteFunc: func(q workqueue.RateLimitingInterface, e event.DeleteEvent) { + q.Add(reconcile.ReconcileRequest{NamespacedName: types.NamespacedName{ + Name: e.Meta.GetName(), + Namespace: e.Meta.GetNamespace(), + }}) + }, + GenericFunc: func(q workqueue.RateLimitingInterface, e event.GenericEvent) { + q.Add(reconcile.ReconcileRequest{NamespacedName: types.NamespacedName{ + Name: e.Meta.GetName(), + Namespace: e.Meta.GetNamespace(), + }}) + }, + }, + ) +} diff --git a/pkg/ctrl/example/main.go b/pkg/ctrl/example/main.go new file mode 100644 index 0000000000..29452199c3 --- /dev/null +++ b/pkg/ctrl/example/main.go @@ -0,0 +1,119 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "flag" + "log" + + "github.com/kubernetes-sigs/kubebuilder/pkg/client" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/eventhandler" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/reconcile" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/source" + logf "github.com/kubernetes-sigs/kubebuilder/pkg/log" + "github.com/kubernetes-sigs/kubebuilder/pkg/signals" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" +) + +func main() { + flag.Parse() + logf.SetLogger(logf.ZapLogger(false)) + + // Setup a ControllerManager + manager, err := ctrl.NewControllerManager(ctrl.ControllerManagerArgs{}) + if err != nil { + log.Fatal(err) + } + + // Setup a new controller to Reconcile ReplicaSets + c, err := manager.NewController( + ctrl.ControllerArgs{Name: "foo-controller", MaxConcurrentReconciles: 1}, + &ReconcileReplicaSet{client: manager.GetClient()}, + ) + if err != nil { + log.Fatal(err) + } + + err = c.Watch( + // Watch ReplicaSets + &source.KindSource{Type: &appsv1.ReplicaSet{}}, + // Enqueue ReplicaSet object key + &eventhandler.EnqueueHandler{}) + if err != nil { + log.Fatal(err) + } + + err = c.Watch( + // Watch Pods + &source.KindSource{Type: &corev1.Pod{}}, + // Enqueue Owning ReplicaSet object key + &eventhandler.EnqueueOwnerHandler{OwnerType: &appsv1.ReplicaSet{}, IsController: true}) + if err != nil { + log.Fatal(err) + } + + log.Fatal(manager.Start(signals.SetupSignalHandler())) +} + +// ReconcileReplicaSet reconciles ReplicaSets +type ReconcileReplicaSet struct { + client client.Interface +} + +// Implement reconcile.reconcile so the controller can reconcile objects +var _ reconcile.Reconcile = &ReconcileReplicaSet{} + +func (r *ReconcileReplicaSet) Reconcile(request reconcile.ReconcileRequest) (reconcile.ReconcileResult, error) { + // Fetch the ReplicaSet from the cache + rs := &appsv1.ReplicaSet{} + err := r.client.Get(context.TODO(), request.NamespacedName, rs) + if errors.IsNotFound(err) { + log.Printf("Could not find ReplicaSet %v.\n", request) + return reconcile.ReconcileResult{}, nil + } + + if err != nil { + log.Printf("Could not fetch ReplicaSet %v for %+v\n", err, request) + return reconcile.ReconcileResult{}, err + } + + // Print the ReplicaSet + log.Printf("ReplicaSet Name %s Namespace %s, Pod Name: %s\n", + rs.Name, rs.Namespace, rs.Spec.Template.Spec.Containers[0].Name) + + // Set the label if it is missing + if rs.Labels == nil { + rs.Labels = map[string]string{} + } + if rs.Labels["hello"] == "world" { + return reconcile.ReconcileResult{}, nil + } + + // Update the ReplicaSet + rs.Labels["hello"] = "world" + err = r.client.Update(context.TODO(), rs) + if err != nil { + log.Printf("Could not write ReplicaSet %v\n", err) + return reconcile.ReconcileResult{}, err + } + + return reconcile.ReconcileResult{}, nil +} diff --git a/pkg/ctrl/example_test.go b/pkg/ctrl/example_test.go new file mode 100644 index 0000000000..f56a18701f --- /dev/null +++ b/pkg/ctrl/example_test.go @@ -0,0 +1,87 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ctrl_test + +import ( + "log" + + "github.com/kubernetes-sigs/kubebuilder/pkg/config" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/event" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/eventhandler" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/predicate" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/reconcile" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/source" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" +) + +// This example creates a new controller named "pod-controller" with a no-op reconcile function and registers +// it with the DefaultControllerManager. +func ExampleController() { + cm, err := ctrl.NewControllerManager(ctrl.ControllerManagerArgs{Config: config.GetConfigOrDie()}) + if err != nil { + log.Fatal(err) + } + _, err = cm.NewController( + ctrl.ControllerArgs{Name: "pod-controller", MaxConcurrentReconciles: 1}, + reconcile.ReconcileFunc(func(o reconcile.ReconcileRequest) (reconcile.ReconcileResult, error) { + // Your business logic to implement the API by creating, updating, deleting objects goes here. + return reconcile.ReconcileResult{}, nil + }), + ) + if err != nil { + log.Fatal(err) + } +} + +// This example watches Pods and enqueues ReconcileRequests with the changed Pod Name and Namespace. +func ExampleController_Watch_1() { + cm, err := ctrl.NewControllerManager(ctrl.ControllerManagerArgs{Config: config.GetConfigOrDie()}) + if err != nil { + log.Fatal(err) + } + c, err := cm.NewController(ctrl.ControllerArgs{Name: "foo-controller"}, nil) + if err != nil { + log.Fatal(err) + } + err = c.Watch(&source.KindSource{Type: &corev1.Pod{}}, &eventhandler.EnqueueHandler{}) + if err != nil { + log.Fatal(err) + } +} + +// This example watches Deployments and enqueues ReconcileRequests with the change Deployment Name and Namespace +// iff 1. the Event is not Update or 2. the Generation of the Deployment object changed in the Update. +func ExampleController_Watch_2() { + cm, err := ctrl.NewControllerManager(ctrl.ControllerManagerArgs{Config: config.GetConfigOrDie()}) + if err != nil { + log.Fatal(err) + } + c, err := cm.NewController(ctrl.ControllerArgs{Name: "foo-controller"}, nil) + if err != nil { + log.Fatal(err) + } + err = c.Watch(&source.KindSource{Type: &appsv1.Deployment{}}, &eventhandler.EnqueueHandler{}, + predicate.PredicateFuncs{UpdateFunc: func(e event.UpdateEvent) bool { + return e.MetaOld.GetGeneration() != e.MetaNew.GetGeneration() + }}, + ) + if err != nil { + log.Fatal(err) + } +} diff --git a/pkg/ctrl/inject/doc.go b/pkg/ctrl/inject/doc.go new file mode 100644 index 0000000000..71f4321e6e --- /dev/null +++ b/pkg/ctrl/inject/doc.go @@ -0,0 +1,22 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* +Package inject defines interfaces and functions for propagating dependencies from a ControllerManager to +the components registered with it. Dependencies are propagated to Reconcile, Source, EventHandler and Predicate +objects which implement the Injectable interfaces. +*/ +package inject diff --git a/pkg/ctrl/inject/inject.go b/pkg/ctrl/inject/inject.go new file mode 100644 index 0000000000..23ef23a3e3 --- /dev/null +++ b/pkg/ctrl/inject/inject.go @@ -0,0 +1,76 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package inject + +import ( + "github.com/kubernetes-sigs/kubebuilder/pkg/client" + "github.com/kubernetes-sigs/kubebuilder/pkg/informer" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" +) + +// Informers is used by the ControllerManager to inject Informers into Sources, EventHandlers, Predicates, and +// Reconciles +type Informers interface { + InjectInformers(informer.Informers) error +} + +func InjectInformers(c informer.Informers, i interface{}) (bool, error) { + if s, ok := i.(Informers); ok { + return true, s.InjectInformers(c) + } + return false, nil +} + +// Config is used by the ControllerManager to inject Config into Sources, EventHandlers, Predicates, and +// Reconciles +type Config interface { + InjectConfig(*rest.Config) error +} + +func InjectConfig(c *rest.Config, i interface{}) (bool, error) { + if s, ok := i.(Config); ok { + return true, s.InjectConfig(c) + } + return false, nil +} + +// Client is used by the ControllerManager to inject Client into Sources, EventHandlers, Predicates, and +// Reconciles +type Client interface { + InjectClient(client.Interface) error +} + +func InjectClient(cacheClient client.Interface, i interface{}) (bool, error) { + if s, ok := i.(Client); ok { + return true, s.InjectClient(cacheClient) + } + return false, nil +} + +// Scheme is used by the ControllerManager to inject Scheme into Sources, EventHandlers, Predicates, and +// Reconciles +type Scheme interface { + InjectScheme(scheme *runtime.Scheme) error +} + +func InjectScheme(s *runtime.Scheme, i interface{}) (bool, error) { + if is, ok := i.(Scheme); ok { + return true, is.InjectScheme(s) + } + return false, nil +} diff --git a/pkg/ctrl/predicate/doc.go b/pkg/ctrl/predicate/doc.go new file mode 100644 index 0000000000..e498107ef7 --- /dev/null +++ b/pkg/ctrl/predicate/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* +Package predicate defines Predicates used by Controllers to filter Events before they are provided to EventHandlers. +*/ +package predicate diff --git a/pkg/ctrl/predicate/example_test.go b/pkg/ctrl/predicate/example_test.go new file mode 100644 index 0000000000..437a04cebb --- /dev/null +++ b/pkg/ctrl/predicate/example_test.go @@ -0,0 +1,33 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package predicate_test + +import ( + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/event" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/predicate" +) + +var p predicate.Predicate + +// This example creates a new Predicate to drop Update Events where the Generation has not changed. +func ExamplePredicateFunc() { + p = predicate.PredicateFuncs{ + UpdateFunc: func(e event.UpdateEvent) bool { + return e.MetaOld.GetGeneration() != e.MetaNew.GetGeneration() + }, + } +} diff --git a/pkg/ctrl/predicate/predicate.go b/pkg/ctrl/predicate/predicate.go new file mode 100644 index 0000000000..b22253e0af --- /dev/null +++ b/pkg/ctrl/predicate/predicate.go @@ -0,0 +1,78 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package predicate + +import "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/event" + +// Predicate filters events before enqueuing the keys. +type Predicate interface { + // Create returns true if the Create event should be processed + Create(event.CreateEvent) bool + + // Delete returns true if the Delete event should be processed + Delete(event.DeleteEvent) bool + + // Update returns true if the Update event should be processed + Update(event.UpdateEvent) bool + + // Generic returns true if the Generic event should be processed + Generic(event.GenericEvent) bool +} + +var _ Predicate = PredicateFuncs{} + +// PredicateFunc is a function that implements Predicate. +type PredicateFuncs struct { + // Create returns true if the Create event should be processed + CreateFunc func(event.CreateEvent) bool + + // Delete returns true if the Delete event should be processed + DeleteFunc func(event.DeleteEvent) bool + + // Update returns true if the Update event should be processed + UpdateFunc func(event.UpdateEvent) bool + + // Generic returns true if the Generic event should be processed + GenericFunc func(event.GenericEvent) bool +} + +func (p PredicateFuncs) Create(e event.CreateEvent) bool { + if p.CreateFunc != nil { + return p.CreateFunc(e) + } + return true +} +func (p PredicateFuncs) Delete(e event.DeleteEvent) bool { + if p.DeleteFunc != nil { + return p.DeleteFunc(e) + } + return true +} + +func (p PredicateFuncs) Update(e event.UpdateEvent) bool { + if p.UpdateFunc != nil { + return p.UpdateFunc(e) + } + return true +} + +func (p PredicateFuncs) Generic(e event.GenericEvent) bool { + if p.GenericFunc != nil { + return p.GenericFunc(e) + } + return true +} diff --git a/pkg/ctrl/predicate/predicate_suite_test.go b/pkg/ctrl/predicate/predicate_suite_test.go new file mode 100644 index 0000000000..a979335fc3 --- /dev/null +++ b/pkg/ctrl/predicate/predicate_suite_test.go @@ -0,0 +1,35 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package predicate_test + +import ( + "testing" + + logf "github.com/kubernetes-sigs/kubebuilder/pkg/log" + "github.com/kubernetes-sigs/kubebuilder/pkg/test" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestPredicate(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecsWithDefaultAndCustomReporters(t, "Predicate Suite", []Reporter{test.NewlineReporter{}}) +} + +var _ = BeforeSuite(func() { + logf.SetLogger(logf.ZapLogger(true)) +}) diff --git a/pkg/ctrl/predicate/predicate_test.go b/pkg/ctrl/predicate/predicate_test.go new file mode 100644 index 0000000000..499af0ece1 --- /dev/null +++ b/pkg/ctrl/predicate/predicate_test.go @@ -0,0 +1,178 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package predicate_test + +import ( + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/event" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/predicate" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("Predicate", func() { + var pod *corev1.Pod + BeforeEach(func() { + pod = &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Namespace: "biz", Name: "baz"}, + } + }) + + Describe("PredicateFuncs", func() { + failingFuncs := predicate.PredicateFuncs{ + CreateFunc: func(event.CreateEvent) bool { + defer GinkgoRecover() + Fail("Did not expect CreateFunc to be called.") + return false + }, + DeleteFunc: func(event.DeleteEvent) bool { + defer GinkgoRecover() + Fail("Did not expect DeleteFunc to be called.") + return false + }, + UpdateFunc: func(event.UpdateEvent) bool { + defer GinkgoRecover() + Fail("Did not expect UpdateFunc to be called.") + return false + }, + GenericFunc: func(event.GenericEvent) bool { + defer GinkgoRecover() + Fail("Did not expect GenericFunc to be called.") + return false + }, + } + + It("should call Create", func(done Done) { + instance := failingFuncs + instance.CreateFunc = func(evt event.CreateEvent) bool { + defer GinkgoRecover() + Expect(evt.Meta).To(Equal(pod.GetObjectMeta())) + Expect(evt.Object).To(Equal(pod)) + return false + } + evt := event.CreateEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + Expect(instance.Create(evt)).To(BeFalse()) + + instance.CreateFunc = func(evt event.CreateEvent) bool { + defer GinkgoRecover() + Expect(evt.Meta).To(Equal(pod.GetObjectMeta())) + Expect(evt.Object).To(Equal(pod)) + return true + } + Expect(instance.Create(evt)).To(BeTrue()) + + instance.CreateFunc = nil + Expect(instance.Create(evt)).To(BeTrue()) + close(done) + }) + + It("should call Update", func(done Done) { + newPod := pod.DeepCopy() + newPod.Name = "baz2" + newPod.Namespace = "biz2" + + instance := failingFuncs + instance.UpdateFunc = func(evt event.UpdateEvent) bool { + defer GinkgoRecover() + Expect(evt.MetaOld).To(Equal(pod.GetObjectMeta())) + Expect(evt.ObjectOld).To(Equal(pod)) + Expect(evt.MetaNew).To(Equal(newPod.GetObjectMeta())) + Expect(evt.ObjectNew).To(Equal(newPod)) + return false + } + evt := event.UpdateEvent{ + ObjectOld: pod, + MetaOld: pod.GetObjectMeta(), + ObjectNew: newPod, + MetaNew: newPod.GetObjectMeta(), + } + Expect(instance.Update(evt)).To(BeFalse()) + + instance.UpdateFunc = func(evt event.UpdateEvent) bool { + defer GinkgoRecover() + Expect(evt.MetaOld).To(Equal(pod.GetObjectMeta())) + Expect(evt.ObjectOld).To(Equal(pod)) + Expect(evt.MetaNew).To(Equal(newPod.GetObjectMeta())) + Expect(evt.ObjectNew).To(Equal(newPod)) + return true + } + Expect(instance.Update(evt)).To(BeTrue()) + + instance.UpdateFunc = nil + Expect(instance.Update(evt)).To(BeTrue()) + close(done) + }) + + It("should call Delete", func(done Done) { + instance := failingFuncs + instance.DeleteFunc = func(evt event.DeleteEvent) bool { + defer GinkgoRecover() + Expect(evt.Meta).To(Equal(pod.GetObjectMeta())) + Expect(evt.Object).To(Equal(pod)) + return false + } + evt := event.DeleteEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + Expect(instance.Delete(evt)).To(BeFalse()) + + instance.DeleteFunc = func(evt event.DeleteEvent) bool { + defer GinkgoRecover() + Expect(evt.Meta).To(Equal(pod.GetObjectMeta())) + Expect(evt.Object).To(Equal(pod)) + return true + } + Expect(instance.Delete(evt)).To(BeTrue()) + + instance.DeleteFunc = nil + Expect(instance.Delete(evt)).To(BeTrue()) + close(done) + }) + + It("should call Generic", func(done Done) { + instance := failingFuncs + instance.GenericFunc = func(evt event.GenericEvent) bool { + defer GinkgoRecover() + Expect(evt.Meta).To(Equal(pod.GetObjectMeta())) + Expect(evt.Object).To(Equal(pod)) + return false + } + evt := event.GenericEvent{ + Object: pod, + Meta: pod.GetObjectMeta(), + } + Expect(instance.Generic(evt)).To(BeFalse()) + + instance.GenericFunc = func(evt event.GenericEvent) bool { + defer GinkgoRecover() + Expect(evt.Meta).To(Equal(pod.GetObjectMeta())) + Expect(evt.Object).To(Equal(pod)) + return true + } + Expect(instance.Generic(evt)).To(BeTrue()) + + instance.GenericFunc = nil + Expect(instance.Generic(evt)).To(BeTrue()) + close(done) + }) + }) +}) diff --git a/pkg/ctrl/reconcile/doc.go b/pkg/ctrl/reconcile/doc.go new file mode 100644 index 0000000000..939fbab5b1 --- /dev/null +++ b/pkg/ctrl/reconcile/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* +Package reconcile defines the reconcile interface used to implement Kubernetes APIs. +*/ +package reconcile diff --git a/pkg/ctrl/reconcile/example_test.go b/pkg/ctrl/reconcile/example_test.go new file mode 100644 index 0000000000..46f830745c --- /dev/null +++ b/pkg/ctrl/reconcile/example_test.go @@ -0,0 +1,44 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package reconcile_test + +import ( + "fmt" + + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/reconcile" + "k8s.io/apimachinery/pkg/types" +) + +// This example implements a simple no-op reconcile function that prints the object to be Reconciled. +func ExampleReconcileFunc() { + r := reconcile.ReconcileFunc(func(o reconcile.ReconcileRequest) (reconcile.ReconcileResult, error) { + // Create your business logic to create, update, delete objects here. + fmt.Printf("Name: %s, Namespace: %s", o.Name, o.Namespace) + return reconcile.ReconcileResult{}, nil + }) + + r.Reconcile(reconcile.ReconcileRequest{types.NamespacedName{Namespace: "default", Name: "test"}}) + + // Output: Name: test, Namespace: default +} + +// This example declares a simple type that implements reconcile. +func ExampleReconcile() { + type MyReconcileImplementation struct { + reconcile.ReconcileFunc + } +} diff --git a/pkg/ctrl/reconcile/reconcile.go b/pkg/ctrl/reconcile/reconcile.go new file mode 100644 index 0000000000..409b5ff6a6 --- /dev/null +++ b/pkg/ctrl/reconcile/reconcile.go @@ -0,0 +1,84 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package reconcile + +import ( + "k8s.io/apimachinery/pkg/types" +) + +// ReconcileResult contains the result of a reconcile. +type ReconcileResult struct { + // Requeue tells the Controller to requeue the reconcile key. Defaults to false. + Requeue bool +} + +// ReconcileRequest contains the information necessary to reconcile a Kubernetes object. This includes the +// information to uniquely identify the object - its Name and Namespace. It does NOT contain information about +// any specific Event or the object contents itself. +type ReconcileRequest struct { + // NamespacedName is the name and namespace of the object to reconcile. + types.NamespacedName +} + +/* +reconcile implements a Kubernetes API for a specific Resource by Creating, Updating or Deleting Kubernetes +objects, or by making changes to systems external to the cluster (e.g. cloudproviders, github, etc). + +reconcile implementations compare the state specified in an object by a user against the actual cluster state, +and then perform operations to make the actual cluster state reflect the state specified by the user. + +Typically, reconcile is triggered by a Controller in response to cluster Events (e.g. Creating, Updating, +Deleting Kubernetes objects) or external Events (GitHub Webhooks, polling external sources, etc). + +Example reconcile Logic: + + * Read an object and all the Pods it owns. + * Observe that the object spec specifies 5 replicas but actual cluster contains only 1 Pod replica. + * Create 4 Pods and set their OwnerReferences to the object. + +reconcile may be implemented as either a type: + + type reconcile struct {} + + func (reconcile) reconcile(ctrl.ReconcileRequest) (ctrl.ReconcileResult, error) { + // Implement business logic of reading and writing objects here + return ctrl.ReconcileResult{}, nil + } + +Or as a function: + + ctrl.ReconcileFunc(func(o ctrl.ReconcileRequest) (ctrl.ReconcileResult, error) { + // Implement business logic of reading and writing objects here + return ctrl.ReconcileResult{}, nil + }) + +Reconciliation is level-based, meaning action isn't driven off changes in individual Events, but instead is +driven by actual cluster state read from the apiserver or a local cache. +For example if responding to a Pod Delete Event, the ReconcileRequest won't contain that a Pod was deleted, +instead the reconcile function observes this when reading the cluster state and seeing the Pod as missing. +*/ +type Reconcile interface { + // reconcile performs a full reconciliation for the object referred to by the ReconcileRequest. + Reconcile(ReconcileRequest) (ReconcileResult, error) +} + +// ReconcileFunc is a function that implements the reconcile interface. +type ReconcileFunc func(ReconcileRequest) (ReconcileResult, error) + +var _ Reconcile = ReconcileFunc(nil) + +func (r ReconcileFunc) Reconcile(o ReconcileRequest) (ReconcileResult, error) { return r(o) } diff --git a/pkg/ctrl/reconcile/reconcile_suite_test.go b/pkg/ctrl/reconcile/reconcile_suite_test.go new file mode 100644 index 0000000000..37f59b040d --- /dev/null +++ b/pkg/ctrl/reconcile/reconcile_suite_test.go @@ -0,0 +1,35 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package reconcile_test + +import ( + "testing" + + logf "github.com/kubernetes-sigs/kubebuilder/pkg/log" + "github.com/kubernetes-sigs/kubebuilder/pkg/test" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestReconcile(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecsWithDefaultAndCustomReporters(t, "reconcile Suite", []Reporter{test.NewlineReporter{}}) +} + +var _ = BeforeSuite(func() { + logf.SetLogger(logf.ZapLogger(true)) +}) diff --git a/pkg/ctrl/reconcile/reconcile_test.go b/pkg/ctrl/reconcile/reconcile_test.go new file mode 100644 index 0000000000..22eac2bf49 --- /dev/null +++ b/pkg/ctrl/reconcile/reconcile_test.go @@ -0,0 +1,69 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package reconcile_test + +import ( + "fmt" + + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/reconcile" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/types" +) + +var _ = Describe("reconcile", func() { + Describe("ReconcileFunc", func() { + It("should call the function with the request and return a nil error.", func() { + request := reconcile.ReconcileRequest{ + NamespacedName: types.NamespacedName{Name: "foo", Namespace: "bar"}, + } + result := reconcile.ReconcileResult{ + Requeue: true, + } + + instance := reconcile.ReconcileFunc(func(r reconcile.ReconcileRequest) (reconcile.ReconcileResult, error) { + defer GinkgoRecover() + Expect(r).To(Equal(request)) + + return result, nil + }) + actualResult, actualErr := instance(request) + Expect(actualResult).To(Equal(result)) + Expect(actualErr).NotTo(HaveOccurred()) + }) + + It("should call the function with the request and return an error.", func() { + request := reconcile.ReconcileRequest{ + NamespacedName: types.NamespacedName{Name: "foo", Namespace: "bar"}, + } + result := reconcile.ReconcileResult{ + Requeue: false, + } + err := fmt.Errorf("hello world") + + instance := reconcile.ReconcileFunc(func(r reconcile.ReconcileRequest) (reconcile.ReconcileResult, error) { + defer GinkgoRecover() + Expect(r).To(Equal(request)) + + return result, err + }) + actualResult, actualErr := instance(request) + Expect(actualResult).To(Equal(result)) + Expect(actualErr).To(Equal(err)) + }) + }) +}) diff --git a/pkg/ctrl/source/doc.go b/pkg/ctrl/source/doc.go new file mode 100644 index 0000000000..ed42c8269b --- /dev/null +++ b/pkg/ctrl/source/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* +Package source provides Event streams that Controllers use to trigger Reconciles. +*/ +package source diff --git a/pkg/ctrl/source/example_test.go b/pkg/ctrl/source/example_test.go new file mode 100644 index 0000000000..5e9874d560 --- /dev/null +++ b/pkg/ctrl/source/example_test.go @@ -0,0 +1,47 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source_test + +import ( + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/event" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/eventhandler" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/source" + "k8s.io/api/core/v1" +) + +var controller ctrl.Controller + +// This example Watches for Pod Events (e.g. Create / Update / Delete) and enqueues a ReconcileRequest +// with the Name and Namespace of the Pod. +func ExampleKindSource() { + controller.Watch( + &source.KindSource{Type: &v1.Pod{}}, + &eventhandler.EnqueueHandler{}, + ) +} + +// This example reads GenericEvents from a channel and enqueues a ReconcileRequest containing the Name and Namespace +// provided by the event. +func ExampleChannelSource() { + events := make(chan event.GenericEvent) + + controller.Watch( + source.ChannelSource(events), + &eventhandler.EnqueueHandler{}, + ) +} diff --git a/pkg/ctrl/source/internal/eventsource.go b/pkg/ctrl/source/internal/eventsource.go new file mode 100644 index 0000000000..19dba89021 --- /dev/null +++ b/pkg/ctrl/source/internal/eventsource.go @@ -0,0 +1,154 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package internal + +import ( + "fmt" + + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/event" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/eventhandler" + logf "github.com/kubernetes-sigs/kubebuilder/pkg/log" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var log = logf.KBLog.WithName("source").WithName("EventHandler") + +var _ cache.ResourceEventHandler = EventHandler{} + +// EventHandler adapts a eventhandler.EventHandler interface to a cache.ResourceEventHandler interface +type EventHandler struct { + EventHandler eventhandler.EventHandler + Queue workqueue.RateLimitingInterface +} + +func (e EventHandler) OnAdd(obj interface{}) { + c := event.CreateEvent{} + + // Pull metav1.Object out of the object + if o, err := meta.Accessor(obj); err == nil { + c.Meta = o + } else { + log.Error(err, "OnAdd missing Meta", + "Object", obj, "Type", fmt.Sprintf("%T", obj)) + return + } + + // Pull the runtime.Object out of the object + if o, ok := obj.(runtime.Object); ok { + c.Object = o + } else { + log.Error(nil, "OnAdd missing runtime.Object", + "Object", obj, "Type", fmt.Sprintf("%T", obj)) + return + } + + // Invoke create handler + e.EventHandler.Create(e.Queue, c) +} + +func (e EventHandler) OnUpdate(oldObj, newObj interface{}) { + u := event.UpdateEvent{} + + // Pull metav1.Object out of the object + if o, err := meta.Accessor(oldObj); err == nil { + u.MetaOld = o + } else { + log.Error(err, "OnUpdate missing MetaOld", + "Object", oldObj, "Type", fmt.Sprintf("%T", oldObj)) + return + } + + // Pull the runtime.Object out of the object + if o, ok := oldObj.(runtime.Object); ok { + u.ObjectOld = o + } else { + log.Error(nil, "OnUpdate missing ObjectOld", + "Object", oldObj, "Type", fmt.Sprintf("%T", oldObj)) + return + } + + // Pull metav1.Object out of the object + if o, err := meta.Accessor(newObj); err == nil { + u.MetaNew = o + } else { + log.Error(err, "OnUpdate missing MetaNew", + "Object", newObj, "Type", fmt.Sprintf("%T", newObj)) + return + } + + // Pull the runtime.Object out of the object + if o, ok := newObj.(runtime.Object); ok { + u.ObjectNew = o + } else { + log.Error(nil, "OnUpdate missing ObjectNew", + "Object", oldObj, "Type", fmt.Sprintf("%T", oldObj)) + return + } + + // Invoke update handler + e.EventHandler.Update(e.Queue, u) +} + +func (e EventHandler) OnDelete(obj interface{}) { + c := event.DeleteEvent{} + + // Deal with tombstone events by pulling the object out. Tombstone events wrap the object in a + // DeleteFinalStateUnknown struct, so the object needs to be pulled out. + // Copied from sample-controller + // This should never happen if we aren't missing events, which we have concluded that we are not + // and made decisions off of this belief. Maybe this shouldn't be here? + var ok bool + if _, ok = obj.(metav1.Object); !ok { + // If the object doesn't have Metadata, assume it is a tombstone object of type DeletedFinalStateUnknown + tombstone, ok := obj.(cache.DeletedFinalStateUnknown) + if !ok { + log.Error(nil, "Error decoding objects. Expected cache.DeletedFinalStateUnknown", + "Type", fmt.Sprintf("%T", obj), + "Object", obj) + return + } + + // Set obj to the tombstone obj + obj = tombstone.Obj + } + + // Pull metav1.Object out of the object + if o, err := meta.Accessor(obj); err == nil { + c.Meta = o + } else { + log.Error(err, "OnDelete missing Meta", + "Object", obj, "Type", fmt.Sprintf("%T", obj)) + return + } + + // Pull the runtime.Object out of the object + if o, ok := obj.(runtime.Object); ok { + c.Object = o + } else { + log.Error(nil, "OnDelete missing runtime.Object", + "Object", obj, "Type", fmt.Sprintf("%T", obj)) + return + } + + // Invoke delete handler + e.EventHandler.Delete(e.Queue, c) +} diff --git a/pkg/ctrl/source/internal/internal_suite_test.go b/pkg/ctrl/source/internal/internal_suite_test.go new file mode 100644 index 0000000000..3216784fce --- /dev/null +++ b/pkg/ctrl/source/internal/internal_suite_test.go @@ -0,0 +1,35 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package internal_test + +import ( + "testing" + + logf "github.com/kubernetes-sigs/kubebuilder/pkg/log" + "github.com/kubernetes-sigs/kubebuilder/pkg/test" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestInternal(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecsWithDefaultAndCustomReporters(t, "Internal Suite", []Reporter{test.NewlineReporter{}}) +} + +var _ = BeforeSuite(func() { + logf.SetLogger(logf.ZapLogger(true)) +}) diff --git a/pkg/ctrl/source/internal/internal_test.go b/pkg/ctrl/source/internal/internal_test.go new file mode 100644 index 0000000000..82164ff71b --- /dev/null +++ b/pkg/ctrl/source/internal/internal_test.go @@ -0,0 +1,177 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package internal_test + +import ( + "time" + + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/event" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/eventhandler" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/source/internal" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" + + corev1 "k8s.io/api/core/v1" +) + +var _ = Describe("Internal", func() { + + var instance internal.EventHandler + var funcs *eventhandler.EventHandlerFuncs + BeforeEach(func() { + funcs = &eventhandler.EventHandlerFuncs{ + CreateFunc: func(workqueue.RateLimitingInterface, event.CreateEvent) { + defer GinkgoRecover() + Fail("Did not expect CreateEvent to be called.") + }, + DeleteFunc: func(q workqueue.RateLimitingInterface, e event.DeleteEvent) { + defer GinkgoRecover() + Fail("Did not expect DeleteEvent to be called.") + }, + UpdateFunc: func(workqueue.RateLimitingInterface, event.UpdateEvent) { + defer GinkgoRecover() + Fail("Did not expect UpdateEvent to be called.") + }, + GenericFunc: func(workqueue.RateLimitingInterface, event.GenericEvent) { + defer GinkgoRecover() + Fail("Did not expect GenericEvent to be called.") + }, + } + instance = internal.EventHandler{ + Queue: Queue{}, + EventHandler: funcs, + } + }) + + Describe("EventHandler", func() { + var pod, newPod *corev1.Pod + + BeforeEach(func() { + pod = &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "test", Image: "test"}}, + }, + } + newPod = pod.DeepCopy() + newPod.Labels = map[string]string{"foo": "bar"} + }) + + It("should create a CreateEvent", func(done Done) { + funcs.CreateFunc = func(q workqueue.RateLimitingInterface, evt event.CreateEvent) { + defer GinkgoRecover() + Expect(q).To(Equal(instance.Queue)) + m, err := meta.Accessor(pod) + Expect(err).NotTo(HaveOccurred()) + Expect(evt.Meta).To(Equal(m)) + Expect(evt.Object).To(Equal(pod)) + } + instance.OnAdd(pod) + close(done) + }) + + It("should create an UpdateEvent", func(done Done) { + funcs.UpdateFunc = func(q workqueue.RateLimitingInterface, evt event.UpdateEvent) { + defer GinkgoRecover() + Expect(q).To(Equal(instance.Queue)) + + m, err := meta.Accessor(pod) + Expect(err).NotTo(HaveOccurred()) + Expect(evt.MetaOld).To(Equal(m)) + Expect(evt.ObjectOld).To(Equal(pod)) + + m, err = meta.Accessor(newPod) + Expect(err).NotTo(HaveOccurred()) + Expect(evt.MetaNew).To(Equal(m)) + Expect(evt.ObjectNew).To(Equal(newPod)) + } + instance.OnUpdate(pod, newPod) + close(done) + }) + + It("should create a DeleteEvent", func(done Done) { + funcs.DeleteFunc = func(q workqueue.RateLimitingInterface, evt event.DeleteEvent) { + defer GinkgoRecover() + Expect(q).To(Equal(instance.Queue)) + + m, err := meta.Accessor(pod) + Expect(err).NotTo(HaveOccurred()) + Expect(evt.Meta).To(Equal(m)) + Expect(evt.Object).To(Equal(pod)) + } + instance.OnDelete(pod) + close(done) + }) + + It("should create a DeleteEvent from a tombstone", func(done Done) { + + tombstone := cache.DeletedFinalStateUnknown{ + Obj: pod, + } + funcs.DeleteFunc = func(q workqueue.RateLimitingInterface, evt event.DeleteEvent) { + defer GinkgoRecover() + Expect(q).To(Equal(instance.Queue)) + m, err := meta.Accessor(pod) + Expect(err).NotTo(HaveOccurred()) + Expect(evt.Meta).To(Equal(m)) + Expect(evt.Object).To(Equal(pod)) + } + + instance.OnDelete(tombstone) + close(done) + }) + + It("should ignore tombstone objects without meta", func(done Done) { + tombstone := cache.DeletedFinalStateUnknown{Obj: Foo{}} + instance.OnDelete(tombstone) + close(done) + }) + It("should ignore objects without meta", func(done Done) { + instance.OnAdd(Foo{}) + instance.OnUpdate(Foo{}, Foo{}) + instance.OnDelete(Foo{}) + close(done) + }) + }) +}) + +type Foo struct{} + +var _ workqueue.RateLimitingInterface = Queue{} + +type Queue struct { + workqueue.Interface +} + +// AddAfter adds an item to the workqueue after the indicated duration has passed +func (q Queue) AddAfter(item interface{}, duration time.Duration) { + q.Add(item) +} + +func (q Queue) AddRateLimited(item interface{}) { + q.Add(item) +} + +func (q Queue) Forget(item interface{}) { + // Do nothing +} + +func (q Queue) NumRequeues(item interface{}) int { + return 0 +} diff --git a/pkg/ctrl/source/source.go b/pkg/ctrl/source/source.go new file mode 100644 index 0000000000..13911d4cf5 --- /dev/null +++ b/pkg/ctrl/source/source.go @@ -0,0 +1,100 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + "fmt" + + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/event" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/eventhandler" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/inject" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/source/internal" + "github.com/kubernetes-sigs/kubebuilder/pkg/informer" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/util/workqueue" + + logf "github.com/kubernetes-sigs/kubebuilder/pkg/log" +) + +// Source is a source of events (eh.g. Create, Update, Delete operations on Kubernetes Objects, Webhook callbacks, etc) +// which should be processed by event.EventHandlers to enqueue ReconcileRequests. +// +// * Use KindSource for events originating in the cluster (eh.g. Pod Create, Pod Update, Deployment Update). +// +// * Use ChannelSource for events originating outside the cluster (eh.g. GitHub Webhook callback, Polling external urls). +type Source interface { + Start(eventhandler.EventHandler, workqueue.RateLimitingInterface) error +} + +// ChannelSource is used to provide a source of events originating outside the cluster +// (eh.g. GitHub Webhook callback). ChannelSource requires the user to wire the external +// source (eh.g. http handler) to write GenericEvents to the underlying channel. +type ChannelSource chan event.GenericEvent + +var _ Source = ChannelSource(make(chan event.GenericEvent)) + +// Start implements Source and should only be called by the Controller. +func (ks ChannelSource) Start( + handler eventhandler.EventHandler, + queue workqueue.RateLimitingInterface) error { + return nil +} + +var log = logf.KBLog.WithName("source").WithName("KindSource") + +// KindSource is used to provide a source of events originating inside the cluster from Watches (eh.g. Pod Create) +type KindSource struct { + // Type is the type of object to watch. e.g. &v1.Pod{} + Type runtime.Object + + // informers used to watch APIs + informers informer.Informers +} + +var _ Source = &KindSource{} + +// Start is internal and should be called only by the Controller to register an EventHandler with the Informer +// to enqueue ReconcileRequests. +func (ks *KindSource) Start(handler eventhandler.EventHandler, queue workqueue.RateLimitingInterface) error { + // Type should have been specified by the user. + if ks.Type == nil { + return fmt.Errorf("must specify KindSource.Type") + } + + // informers should have been injected before Start was called + if ks.informers == nil { + return fmt.Errorf("must call InjectInformers on KindSource before calling Start") + } + + // Lookup the Informer from the Informers and add an EventHandler which populates the Queue + i, err := ks.informers.InformerFor(ks.Type) + if err != nil { + return err + } + i.AddEventHandler(internal.EventHandler{Queue: queue, EventHandler: handler}) + return nil +} + +var _ inject.Informers = &KindSource{} + +// InjectInformers is internal should be called only by the Controller. InjectInformers should be called before Start. +func (ks *KindSource) InjectInformers(i informer.Informers) error { + if ks.informers == nil { + ks.informers = i + } + return nil +} diff --git a/pkg/ctrl/source/source_integration_test.go b/pkg/ctrl/source/source_integration_test.go new file mode 100644 index 0000000000..542c571c44 --- /dev/null +++ b/pkg/ctrl/source/source_integration_test.go @@ -0,0 +1,212 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source_test + +import ( + "fmt" + + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/event" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/eventhandler" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/inject" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/source" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/util/workqueue" +) + +var _ = Describe("Source", func() { + var instance1, instance2 *source.KindSource + var obj runtime.Object + var q workqueue.RateLimitingInterface + var c1, c2 chan interface{} + var ns string + count := 0 + + BeforeEach(func() { + // Create the namespace for the test + ns = fmt.Sprintf("ctrl-source-kindsource-%v", count) + count++ + _, err := clientset.CoreV1().Namespaces().Create(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: ns}, + }) + Expect(err).NotTo(HaveOccurred()) + + q = workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "test") + c1 = make(chan interface{}) + c2 = make(chan interface{}) + }) + + JustBeforeEach(func() { + instance1 = &source.KindSource{Type: obj} + inject.InjectInformers(icache, instance1) + + instance2 = &source.KindSource{Type: obj} + inject.InjectInformers(icache, instance2) + }) + + AfterEach(func() { + err := clientset.CoreV1().Namespaces().Delete(ns, &metav1.DeleteOptions{}) + Expect(err).NotTo(HaveOccurred()) + close(c1) + close(c2) + }) + + Describe("KindSource", func() { + Context("for a Deployment resource", func() { + obj = &appsv1.Deployment{} + + It("should provide Deployment Events", func(done Done) { + var created, updated, deleted *appsv1.Deployment + var err error + + // Get the client and Deployment used to create events + client := clientset.AppsV1().Deployments(ns) + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "deployment-name"}, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"foo": "bar"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"foo": "bar"}}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + } + + // Create an event handler to verify the events + newHandler := func(c chan interface{}) eventhandler.EventHandlerFuncs { + return eventhandler.EventHandlerFuncs{ + CreateFunc: func(rli workqueue.RateLimitingInterface, evt event.CreateEvent) { + defer GinkgoRecover() + Expect(rli).To(Equal(q)) + c <- evt + }, + UpdateFunc: func(rli workqueue.RateLimitingInterface, evt event.UpdateEvent) { + defer GinkgoRecover() + Expect(rli).To(Equal(q)) + c <- evt + }, + DeleteFunc: func(rli workqueue.RateLimitingInterface, evt event.DeleteEvent) { + defer GinkgoRecover() + Expect(rli).To(Equal(q)) + c <- evt + }, + } + } + handler1 := newHandler(c1) + handler2 := newHandler(c2) + + // Create 2 instances + instance1.Start(handler1, q) + instance2.Start(handler2, q) + + // Start the cache + err = icache.Start(stop) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment and expecting the CreateEvent.") + created, err = client.Create(deployment) + Expect(err).NotTo(HaveOccurred()) + Expect(created).NotTo(BeNil()) + + // Check first CreateEvent + evt := <-c1 + createEvt, ok := evt.(event.CreateEvent) + Expect(ok).To(BeTrue(), fmt.Sprintf("expect %T to be %T", evt, event.CreateEvent{})) + Expect(createEvt.Meta).To(Equal(created)) + Expect(createEvt.Object).To(Equal(created)) + + // Check second CreateEvent + evt = <-c2 + createEvt, ok = evt.(event.CreateEvent) + Expect(ok).To(BeTrue(), fmt.Sprintf("expect %T to be %T", evt, event.CreateEvent{})) + Expect(createEvt.Meta).To(Equal(created)) + Expect(createEvt.Object).To(Equal(created)) + + By("Updating a Deployment and expecting the UpdateEvent.") + updated = created.DeepCopy() + updated.Labels = map[string]string{"biz": "buz"} + updated, err = client.Update(updated) + Expect(err).NotTo(HaveOccurred()) + + // Check first UpdateEvent + evt = <-c1 + updateEvt, ok := evt.(event.UpdateEvent) + Expect(ok).To(BeTrue(), fmt.Sprintf("expect %T to be %T", evt, event.UpdateEvent{})) + + Expect(updateEvt.MetaNew).To(Equal(updated)) + Expect(updateEvt.ObjectNew).To(Equal(updated)) + + Expect(updateEvt.MetaOld).To(Equal(created)) + Expect(updateEvt.ObjectOld).To(Equal(created)) + + // Check second UpdateEvent + evt = <-c2 + updateEvt, ok = evt.(event.UpdateEvent) + Expect(ok).To(BeTrue(), fmt.Sprintf("expect %T to be %T", evt, event.UpdateEvent{})) + + Expect(updateEvt.MetaNew).To(Equal(updated)) + Expect(updateEvt.ObjectNew).To(Equal(updated)) + + Expect(updateEvt.MetaOld).To(Equal(created)) + Expect(updateEvt.ObjectOld).To(Equal(created)) + + By("Deleting a Deployment and expecting the Delete.") + deleted = updated.DeepCopy() + err = client.Delete(created.Name, &metav1.DeleteOptions{}) + Expect(err).NotTo(HaveOccurred()) + + deleted.SetResourceVersion("") + evt = <-c1 + deleteEvt, ok := evt.(event.DeleteEvent) + Expect(ok).To(BeTrue(), fmt.Sprintf("expect %T to be %T", evt, event.DeleteEvent{})) + deleteEvt.Meta.SetResourceVersion("") + Expect(deleteEvt.Meta).To(Equal(deleted)) + Expect(deleteEvt.Object).To(Equal(deleted)) + + evt = <-c2 + deleteEvt, ok = evt.(event.DeleteEvent) + Expect(ok).To(BeTrue(), fmt.Sprintf("expect %T to be %T", evt, event.DeleteEvent{})) + deleteEvt.Meta.SetResourceVersion("") + Expect(deleteEvt.Meta).To(Equal(deleted)) + Expect(deleteEvt.Object).To(Equal(deleted)) + + close(done) + }, 5) + }) + + // TODO(pwittrock): Write this test + Context("for a Foo CRD resource", func() { + It("should provide Foo Events", func() { + + }) + }) + }) +}) diff --git a/pkg/ctrl/source/source_suite_test.go b/pkg/ctrl/source/source_suite_test.go new file mode 100644 index 0000000000..3a15c68a79 --- /dev/null +++ b/pkg/ctrl/source/source_suite_test.go @@ -0,0 +1,63 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source_test + +import ( + "testing" + "time" + + "github.com/kubernetes-sigs/kubebuilder/pkg/informer" + logf "github.com/kubernetes-sigs/kubebuilder/pkg/log" + "github.com/kubernetes-sigs/kubebuilder/pkg/test" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +func TestSource(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecsWithDefaultAndCustomReporters(t, "Source Suite", []Reporter{test.NewlineReporter{}}) +} + +var testenv *test.TestEnvironment +var config *rest.Config +var clientset *kubernetes.Clientset +var icache informer.Informers +var stop = make(chan struct{}) + +var _ = BeforeSuite(func() { + logf.SetLogger(logf.ZapLogger(true)) + + testenv = &test.TestEnvironment{} + + var err error + config, err = testenv.Start() + Expect(err).NotTo(HaveOccurred()) + + time.Sleep(1 * time.Second) + + clientset, err = kubernetes.NewForConfig(config) + Expect(err).NotTo(HaveOccurred()) + + icache = &informer.SelfPopulatingInformers{Config: config} +}) + +var _ = AfterSuite(func() { + close(stop) + testenv.Stop() +}) diff --git a/pkg/ctrl/source/source_test.go b/pkg/ctrl/source/source_test.go new file mode 100644 index 0000000000..cd82dd6df4 --- /dev/null +++ b/pkg/ctrl/source/source_test.go @@ -0,0 +1,228 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source_test + +import ( + "fmt" + + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/event" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/eventhandler" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/inject" + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/source" + "github.com/kubernetes-sigs/kubebuilder/pkg/informer/test" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "k8s.io/client-go/util/workqueue" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("Source", func() { + Describe("KindSource", func() { + var c chan struct{} + var p *corev1.Pod + var d *appsv1.Deployment + var ic *test.FakeInformers + + BeforeEach(func() { + ic = &test.FakeInformers{} + c = make(chan struct{}) + p = &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "test", Image: "test"}, + }, + }, + } + d = &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "deployment-name"}, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"foo": "bar"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"foo": "bar"}}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + } + }) + + Context("for a Pod resource", func() { + It("should provide a Pod CreateEvent", func(done Done) { + c := make(chan struct{}) + p := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "test", Image: "test"}, + }, + }, + } + + q := workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "test") + instance := &source.KindSource{ + Type: &corev1.Pod{}, + } + inject.InjectInformers(ic, instance) + err := instance.Start(eventhandler.EventHandlerFuncs{ + CreateFunc: func(q2 workqueue.RateLimitingInterface, evt event.CreateEvent) { + defer GinkgoRecover() + Expect(q2).To(Equal(q)) + Expect(evt.Meta).To(Equal(p)) + Expect(evt.Object).To(Equal(p)) + close(c) + }, + UpdateFunc: func(workqueue.RateLimitingInterface, event.UpdateEvent) { + defer GinkgoRecover() + Fail("Unexpected UpdateEvent") + }, + DeleteFunc: func(workqueue.RateLimitingInterface, event.DeleteEvent) { + defer GinkgoRecover() + Fail("Unexpected DeleteEvent") + }, + GenericFunc: func(workqueue.RateLimitingInterface, event.GenericEvent) { + defer GinkgoRecover() + Fail("Unexpected GenericEvent") + }, + }, q) + Expect(err).NotTo(HaveOccurred()) + + i, err := ic.FakeInformerFor(&corev1.Pod{}) + Expect(err).NotTo(HaveOccurred()) + + i.Add(p) + <-c + close(done) + }) + + It("should provide a Pod UpdateEvent", func(done Done) { + p2 := p.DeepCopy() + p2.SetLabels(map[string]string{"biz": "baz"}) + + ic := &test.FakeInformers{} + q := workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "test") + instance := &source.KindSource{ + Type: &corev1.Pod{}, + } + instance.InjectInformers(ic) + err := instance.Start(eventhandler.EventHandlerFuncs{ + CreateFunc: func(q2 workqueue.RateLimitingInterface, evt event.CreateEvent) { + defer GinkgoRecover() + Fail("Unexpected CreateEvent") + }, + UpdateFunc: func(q2 workqueue.RateLimitingInterface, evt event.UpdateEvent) { + defer GinkgoRecover() + Expect(q2).To(Equal(q)) + Expect(evt.MetaOld).To(Equal(p)) + Expect(evt.ObjectOld).To(Equal(p)) + + Expect(evt.MetaNew).To(Equal(p2)) + Expect(evt.ObjectNew).To(Equal(p2)) + + close(c) + }, + DeleteFunc: func(workqueue.RateLimitingInterface, event.DeleteEvent) { + defer GinkgoRecover() + Fail("Unexpected DeleteEvent") + }, + GenericFunc: func(workqueue.RateLimitingInterface, event.GenericEvent) { + defer GinkgoRecover() + Fail("Unexpected GenericEvent") + }, + }, q) + Expect(err).NotTo(HaveOccurred()) + + i, err := ic.FakeInformerFor(&corev1.Pod{}) + Expect(err).NotTo(HaveOccurred()) + + i.Update(p, p2) + <-c + close(done) + }) + + It("should provide a Pod DeletedEvent", func(done Done) { + c := make(chan struct{}) + p := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "test", Image: "test"}, + }, + }, + } + + q := workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "test") + instance := &source.KindSource{ + Type: &corev1.Pod{}, + } + inject.InjectInformers(ic, instance) + err := instance.Start(eventhandler.EventHandlerFuncs{ + CreateFunc: func(workqueue.RateLimitingInterface, event.CreateEvent) { + defer GinkgoRecover() + Fail("Unexpected DeleteEvent") + }, + UpdateFunc: func(workqueue.RateLimitingInterface, event.UpdateEvent) { + defer GinkgoRecover() + Fail("Unexpected UpdateEvent") + }, + DeleteFunc: func(q2 workqueue.RateLimitingInterface, evt event.DeleteEvent) { + defer GinkgoRecover() + Expect(q2).To(Equal(q)) + Expect(evt.Meta).To(Equal(p)) + Expect(evt.Object).To(Equal(p)) + close(c) + }, + GenericFunc: func(workqueue.RateLimitingInterface, event.GenericEvent) { + defer GinkgoRecover() + Fail("Unexpected GenericEvent") + }, + }, q) + Expect(err).NotTo(HaveOccurred()) + + i, err := ic.FakeInformerFor(&corev1.Pod{}) + Expect(err).NotTo(HaveOccurred()) + + i.Delete(p) + <-c + close(done) + }) + }) + Context("for a Kind not in the cache", func() { + It("should return an error when Start is called", func(done Done) { + ic.Error = fmt.Errorf("test error") + q := workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "test") + + instance := &source.KindSource{ + Type: &corev1.Pod{}, + } + instance.InjectInformers(ic) + err := instance.Start(eventhandler.EventHandlerFuncs{}, q) + Expect(err).To(HaveOccurred()) + + close(done) + }) + }) + }) +}) diff --git a/pkg/ctrl/testing/testing.go b/pkg/ctrl/testing/testing.go new file mode 100644 index 0000000000..51e23ee0c0 --- /dev/null +++ b/pkg/ctrl/testing/testing.go @@ -0,0 +1,55 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package testing + +import ( + "time" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/util/workqueue" +) + +var _ runtime.Object = &ErrorType{} + +// ErrorType implements runtime.Object but isn't registered in any scheme and should cause errors in tests as a result. +type ErrorType struct{} + +func (ErrorType) GetObjectKind() schema.ObjectKind { return nil } +func (ErrorType) DeepCopyObject() runtime.Object { return nil } + +var _ workqueue.RateLimitingInterface = Queue{} + +// Queue implements a RateLimiting queue as a non-ratelimited queue for testing. +// This helps testing by having functions that use a RateLimiting queue synchronously add items to the queue. +type Queue struct { + workqueue.Interface +} + +func (q Queue) AddAfter(item interface{}, duration time.Duration) { + q.Add(item) +} + +func (q Queue) AddRateLimited(item interface{}) { + q.Add(item) +} + +func (q Queue) Forget(item interface{}) {} + +func (q Queue) NumRequeues(item interface{}) int { + return 0 +} diff --git a/pkg/docs/gendocs.go b/pkg/docs/gendocs.go new file mode 100644 index 0000000000..c451a29778 --- /dev/null +++ b/pkg/docs/gendocs.go @@ -0,0 +1,48 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package docs + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "path/filepath" + "strings" + + "github.com/go-openapi/spec" + "k8s.io/kube-openapi/pkg/common" +) + +// WriteOpenAPI writes the openapi json to docs/reference/openapi-spec/swagger.json +func WriteOpenAPI(openapi func(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition) { + defs := openapi(func(name string) spec.Ref { + parts := strings.Split(name, "/") + return spec.MustCreateRef(fmt.Sprintf("#/definitions/%s.%s", + common.EscapeJsonPointer(parts[len(parts)-2]), + common.EscapeJsonPointer(parts[len(parts)-1]))) + }) + + o, err := json.MarshalIndent(defs, "", " ") + if err != nil { + log.Fatalf("Could not Marshal JSON %v\n%v", err, defs) + } + err = ioutil.WriteFile(filepath.Join("docs", "reference", "openapi-spec", "swagger.json"), o, 0700) + if err != nil { + log.Fatalf("%v", err) + } +} diff --git a/pkg/gen/apis/doc.go b/pkg/gen/apis/doc.go new file mode 100644 index 0000000000..06869872ea --- /dev/null +++ b/pkg/gen/apis/doc.go @@ -0,0 +1,65 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* +The apis package describes the comment directives that may be applied to apis / resources +*/ +package apis + +const ( + // Resource annotates a type as a resource + Resource = "// +kubebuilder:resource:path=" + + // Categories annotates a type as belonging to a comma-delimited list of + // categories + Categories = "// +kubebuilder:categories=" + + // Maximum annotates a numeric go struct field for CRD validation + Maximum = "// +kubebuilder:validation:Maximum=" + + // ExclusiveMaximum annotates a numeric go struct field for CRD validation + ExclusiveMaximum = "// +kubebuilder:validation:ExclusiveMaximum=" + + // Minimum annotates a numeric go struct field for CRD validation + Minimum = "// +kubebuilder:validation:Minimum=" + + // ExclusiveMinimum annotates a numeric go struct field for CRD validation + ExclusiveMinimum = "// +kubebuilder:validation:ExclusiveMinimum=" + + // Pattern annotates a string go struct field for CRD validation with a regular expression it must match + Pattern = "// +kubebuilder:validation:Pattern=" + + // Enum specifies the valid values for a field + Enum = "// +kubebuilder:validation:Enum=" + + // MaxLength specifies the maximum length of a string field + MaxLength = "// +kubebuilder:validation:MaxLength=" + + // MinLength specifies the minimum length of a string field + MinLength = "// +kubebuilder:validation:MinLength=" + + // MaxItems specifies the maximum number of items an array or slice field may contain + MaxItems = "// +kubebuilder:validation:MaxItems=" + + // MinItems specifies the minimum number of items an array or slice field may contain + MinItems = "// +kubebuilder:validation:MinItems=" + + // UniqueItems specifies that all values in an array or slice must be unique + UniqueItems = "// +kubebuilder:validation:UniqueItems=" + + // Format annotates a string go struct field for CRD validation with a specific format + Format = "// +kubebuilder:validation:Format=" +) diff --git a/pkg/gen/apis/example_test.go b/pkg/gen/apis/example_test.go new file mode 100644 index 0000000000..6f139e522a --- /dev/null +++ b/pkg/gen/apis/example_test.go @@ -0,0 +1,48 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package apis_test + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func Example() { + // FooSpec defines the desired state of Foo + type FooSpec struct { + // +kubebuilder:validation:Maximum=10 + // +kubebuilder:validation:ExclusiveMinimum=3 + Count int `json:"count"` + } + + // FooStatus defines the observed state of Foo + type FooStatus struct{} + + // +genclient + // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + + // Foo + // +k8s:openapi-gen=true + // +kubebuilder:resource:path=foos + // +kubebuilder:categories=foo,bar,baz + type Foo struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec FooSpec `json:"spec,omitempty"` + Status FooStatus `json:"status,omitempty"` + } +} diff --git a/pkg/gen/controller/doc.go b/pkg/gen/controller/doc.go new file mode 100644 index 0000000000..001c70f2ed --- /dev/null +++ b/pkg/gen/controller/doc.go @@ -0,0 +1,29 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* +The controller package describes comment directives that may be applied to controllers +*/ +package controller + +// Controller annotates a type as being a controller for a specific resource +const Controller = "// +kubebuilder:controller:group=,version=,kind=,resource=" + +// RBAC annotates a controller struct as needing an RBAC rule to run +const RBAC = "// +kubebuilder:rbac:groups=,resources=,verbs=" + +// Informers indicates that an informer must be started for this controller +const Informers = "// +kubebuilder:informers:group=core,version=v1,kind=Pod" \ No newline at end of file diff --git a/pkg/gen/controller/example_test.go b/pkg/gen/controller/example_test.go new file mode 100644 index 0000000000..ab7cd1f90d --- /dev/null +++ b/pkg/gen/controller/example_test.go @@ -0,0 +1,26 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller_test + +func Example() { + // +kubebuilder:controller:group=foo,version=v1beta1,kind=Bar,resource=bars + // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete + // +kubebuilder:informers:group=apps,version=v1,kind=Deployment + // +kubebuilder:rbac:groups=core,resources=pods,verbs=get;watch;list + // +kubebuilder:informers:group=core,version=v1,kind=Pod + type FooController struct{} +} diff --git a/pkg/informer/cache.go b/pkg/informer/cache.go new file mode 100644 index 0000000000..3d2b93708b --- /dev/null +++ b/pkg/informer/cache.go @@ -0,0 +1,188 @@ +package informer + +import ( + "os" + "sync" + "time" + + "github.com/kubernetes-sigs/kubebuilder/pkg/ctrl/common" + logf "github.com/kubernetes-sigs/kubebuilder/pkg/log" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/cache" +) + +var log = logf.KBLog.WithName("informers") + +// Informers knows how to create or fetch informers for different group-version-kinds. +// It's safe to call InformerFor from multiple threads. +type Informers interface { + // InformerFor fetches or constructs an informer for the given object that corresponds to a single + // API kind and resource. + InformerFor(obj runtime.Object) (cache.SharedIndexInformer, error) + // InformerForKind is similar to InformerFor, except that it takes a group-version-kind, instead + // of the underlying object. + InformerForKind(gvk schema.GroupVersionKind) (cache.SharedIndexInformer, error) + // KnownInformersByType returns all informers requested. + KnownInformersByType() map[schema.GroupVersionKind]cache.SharedIndexInformer + // Start runs all the informers known to this cache until the given channel is closed. + // It does not block. + Start(stopCh <-chan struct{}) error +} + +var _ Informers = &SelfPopulatingInformers{} + +type InformerCallback interface { + Call(gvk schema.GroupVersionKind, c cache.SharedIndexInformer) +} + +// SelfPopulatingInformers lazily creates informers, and then caches them for the next time that informer is +// requested. It uses a standard parameter codec constructed based on the given generated scheme. +type SelfPopulatingInformers struct { + Config *rest.Config + Scheme *runtime.Scheme + Mapper meta.RESTMapper + Callbacks []InformerCallback + + once sync.Once + mu sync.Mutex + informersByGVK map[schema.GroupVersionKind]cache.SharedIndexInformer + codecs serializer.CodecFactory + paramCodec runtime.ParameterCodec + started bool + stopCh <-chan struct{} +} + +func (c *SelfPopulatingInformers) init() { + c.once.Do(func() { + // Init a scheme if none provided + if c.Scheme == nil { + c.Scheme = scheme.Scheme + } + + if c.Mapper == nil { + // TODO: don't initialize things that can fail + Mapper, err := common.NewDiscoveryRESTMapper(c.Config) + if err != nil { + log.WithName("setup").Error(err, "Failed to get API Group-Resources") + os.Exit(1) + } + c.Mapper = Mapper + } + + // Setup the codecs + c.codecs = serializer.NewCodecFactory(c.Scheme) + c.paramCodec = runtime.NewParameterCodec(c.Scheme) + c.informersByGVK = make(map[schema.GroupVersionKind]cache.SharedIndexInformer) + }) +} + +func (c *SelfPopulatingInformers) KnownInformersByType() map[schema.GroupVersionKind]cache.SharedIndexInformer { + c.init() + return c.informersByGVK +} + +func (c *SelfPopulatingInformers) InformerForKind(gvk schema.GroupVersionKind) (cache.SharedIndexInformer, error) { + c.init() + obj, err := c.Scheme.New(gvk) + if err != nil { + return nil, err + } + return c.informerFor(gvk, obj) +} + +func (c *SelfPopulatingInformers) InformerFor(obj runtime.Object) (cache.SharedIndexInformer, error) { + c.init() + gvk, err := common.GVKForObject(obj, c.Scheme) + if err != nil { + return nil, err + } + return c.informerFor(gvk, obj) +} + +// informerFor actually fetches or constructs an informer. +func (c *SelfPopulatingInformers) informerFor(gvk schema.GroupVersionKind, obj runtime.Object) (cache.SharedIndexInformer, error) { + c.init() + c.mu.Lock() + defer c.mu.Unlock() + + informer, ok := c.informersByGVK[gvk] + if ok { + for _, callback := range c.Callbacks { + callback.Call(gvk, informer) + } + return informer, nil + } + + client, err := common.RESTClientForGVK(gvk, c.Config, c.codecs) + if err != nil { + return nil, err + } + + mapping, err := c.Mapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return nil, err + } + + listGVK := gvk.GroupVersion().WithKind(gvk.Kind + "List") + listObj, err := c.Scheme.New(listGVK) + if err != nil { + return nil, err + } + + lw := &cache.ListWatch{ + ListFunc: func(opts metav1.ListOptions) (runtime.Object, error) { + res := listObj.DeepCopyObject() + err := client.Get(). + Resource(mapping.Resource). + VersionedParams(&opts, c.paramCodec). + Do(). + Into(res) + return res, err + }, + WatchFunc: func(opts metav1.ListOptions) (watch.Interface, error) { + opts.Watch = true + return client.Get(). + Resource(mapping.Resource). + VersionedParams(&opts, c.paramCodec). + Watch() + }, + } + + res := cache.NewSharedIndexInformer( + lw, obj, 10*time.Hour, cache.Indexers{}, + ) + + c.informersByGVK[gvk] = res + + // Callback that an informer was added + for _, callback := range c.Callbacks { + callback.Call(gvk, res) + } + + // If we already started the informers, start new ones as they get added + if c.started { + go res.Run(c.stopCh) + } + return res, nil +} + +func (c *SelfPopulatingInformers) Start(stopCh <-chan struct{}) error { + c.init() + c.mu.Lock() + defer c.mu.Unlock() + + c.stopCh = stopCh + for _, informer := range c.informersByGVK { + go informer.Run(stopCh) + } + + c.started = true + return nil +} diff --git a/pkg/informer/test/fake_cache.go b/pkg/informer/test/fake_cache.go new file mode 100644 index 0000000000..8aeddf2bf0 --- /dev/null +++ b/pkg/informer/test/fake_cache.go @@ -0,0 +1,100 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package test + +import ( + "github.com/kubernetes-sigs/kubebuilder/pkg/controller/test" + "github.com/kubernetes-sigs/kubebuilder/pkg/informer" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/cache" +) + +var _ informer.Informers = &FakeInformers{} + +type FakeInformers struct { + InformersByGVK map[schema.GroupVersionKind]cache.SharedIndexInformer + Scheme *runtime.Scheme + Error error +} + +func (c *FakeInformers) InformerForKind(gvk schema.GroupVersionKind) (cache.SharedIndexInformer, error) { + if c.Scheme == nil { + c.Scheme = scheme.Scheme + } + obj, _ := c.Scheme.New(gvk) + return c.informerFor(gvk, obj) +} + +func (c *FakeInformers) FakeInformerForKind(gvk schema.GroupVersionKind) (*test.FakeInformer, error) { + if c.Scheme == nil { + c.Scheme = scheme.Scheme + } + obj, _ := c.Scheme.New(gvk) + i, err := c.informerFor(gvk, obj) + if err != nil { + return nil, err + } + return i.(*test.FakeInformer), nil +} + +func (c *FakeInformers) InformerFor(obj runtime.Object) (cache.SharedIndexInformer, error) { + if c.Scheme == nil { + c.Scheme = scheme.Scheme + } + gvks, _, _ := c.Scheme.ObjectKinds(obj) + gvk := gvks[0] + return c.informerFor(gvk, obj) +} + +func (c *FakeInformers) KnownInformersByType() map[schema.GroupVersionKind]cache.SharedIndexInformer { + return c.InformersByGVK +} + +func (c *FakeInformers) FakeInformerFor(obj runtime.Object) (*test.FakeInformer, error) { + if c.Scheme == nil { + c.Scheme = scheme.Scheme + } + gvks, _, _ := c.Scheme.ObjectKinds(obj) + gvk := gvks[0] + i, err := c.informerFor(gvk, obj) + if err != nil { + return nil, err + } + return i.(*test.FakeInformer), nil +} + +func (c *FakeInformers) informerFor(gvk schema.GroupVersionKind, obj runtime.Object) (cache.SharedIndexInformer, error) { + if c.Error != nil { + return nil, c.Error + } + if c.InformersByGVK == nil { + c.InformersByGVK = map[schema.GroupVersionKind]cache.SharedIndexInformer{} + } + informer, ok := c.InformersByGVK[gvk] + if ok { + return informer, nil + } + + c.InformersByGVK[gvk] = &test.FakeInformer{} + return c.InformersByGVK[gvk], nil +} + +func (c *FakeInformers) Start(stopCh <-chan struct{}) error { + return c.Error +} diff --git a/pkg/inject/args/args.go b/pkg/inject/args/args.go new file mode 100644 index 0000000000..9dc6ae074e --- /dev/null +++ b/pkg/inject/args/args.go @@ -0,0 +1,113 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package args + +import ( + "time" + + "github.com/golang/glog" + "github.com/kubernetes-sigs/kubebuilder/pkg/controller" + "github.com/kubernetes-sigs/kubebuilder/pkg/inject/run" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/record" +) + +// InjectArgs are the common arguments for initializing controllers and admission hooks +type InjectArgs struct { + // Config is the rest config to talk to an API server + Config *rest.Config + + // KubernetesClientSet is a clientset to talk to Kuberntes apis + KubernetesClientSet kubernetes.Interface + + // KubernetesInformers contains a Kubernetes informers factory + KubernetesInformers informers.SharedInformerFactory + + // ControllerManager is the controller manager + ControllerManager *controller.ControllerManager + + // EventBroadcaster + EventBroadcaster record.EventBroadcaster +} + +// CreateRecorder returns a new recorder +func (iargs InjectArgs) CreateRecorder(name string) record.EventRecorder { + return iargs.EventBroadcaster.NewRecorder(scheme.Scheme, corev1.EventSource{Component: name}) +} + +// CreateInjectArgs returns new arguments for initializing objects +func CreateInjectArgs(config *rest.Config) InjectArgs { + cs := kubernetes.NewForConfigOrDie(config) + eventBroadcaster := record.NewBroadcaster() + eventBroadcaster.StartLogging(glog.Infof) + eventBroadcaster.StartRecordingToSink(&typedcorev1.EventSinkImpl{Interface: cs.CoreV1().Events("")}) + return InjectArgs{ + Config: config, + KubernetesClientSet: cs, + KubernetesInformers: informers.NewSharedInformerFactory(cs, 2*time.Minute), + ControllerManager: &controller.ControllerManager{}, + EventBroadcaster: eventBroadcaster, + } +} + +// Injector is used by code generators to register code generated objects +type Injector struct { + // CRDs are CRDs that may be created / updated at startup + CRDs []*apiextensionsv1beta1.CustomResourceDefinition + + // PolicyRules are RBAC policy rules that may be installed with the controller + PolicyRules []rbacv1.PolicyRule + + // GroupVersions are the api group versions in the CRDs + GroupVersions []schema.GroupVersion + + // Runnables objects run with RunArguments + Runnables []Runnable + + // RunFns are functions run with RunArguments + RunFns []RunFn + + // ControllerManager is used to register Informers and Controllers + ControllerManager *controller.ControllerManager +} + +// Run will run all of the registered RunFns and Runnables +func (i Injector) Run(a run.RunArguments) error { + for _, r := range i.Runnables { + go r.Run(a) + } + for _, r := range i.RunFns { + go r(a) + } + return nil +} + +// RunFn can be registered with an Injector and run +type RunFn func(arguments run.RunArguments) error + +// Runnable can be registered with an Injector and run +type Runnable interface { + Run(arguments run.RunArguments) error +} diff --git a/pkg/inject/args/doc.go b/pkg/inject/args/doc.go new file mode 100644 index 0000000000..91b7fa6e9e --- /dev/null +++ b/pkg/inject/args/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// The args package contains arguments for running controllers and admission webhooks +package args diff --git a/pkg/inject/args/example_args_test.go b/pkg/inject/args/example_args_test.go new file mode 100644 index 0000000000..fcb7a01ff6 --- /dev/null +++ b/pkg/inject/args/example_args_test.go @@ -0,0 +1,47 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package args_test + +import ( + "flag" + "github.com/kubernetes-sigs/kubebuilder/pkg/config" + "github.com/kubernetes-sigs/kubebuilder/pkg/inject/args" +) + +func Example() { + flag.Parse() + config := config.GetConfigOrDie() + + // Create base arguments for initializing controllers + var _ = args.CreateInjectArgs(config) +} + +func ExampleCreateInjectArgs() { + flag.Parse() + config := config.GetConfigOrDie() + + // Create base arguments for initializing controllers + var _ = args.CreateInjectArgs(config) +} + +func ExampleInjectArgs_CreateRecorder() { + flag.Parse() + config := config.GetConfigOrDie() + + iargs := args.CreateInjectArgs(config) + var _ = iargs.CreateRecorder("ControllerName") +} diff --git a/pkg/inject/run/args.go b/pkg/inject/run/args.go new file mode 100644 index 0000000000..298c19bc3d --- /dev/null +++ b/pkg/inject/run/args.go @@ -0,0 +1,34 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package run + +// RunArguments configures options for running controllers +type RunArguments struct { + // ControllerParallelism is the number of concurrent ReconcileFn routines to run for each GenericController + ControllerParallelism int + + // Stop will shutdown the GenericController when it is closed + Stop <-chan struct{} +} + +// CreateRunArguments returns new run arguments for controllers and admission hooks +func CreateRunArguments() RunArguments { + return RunArguments{ + Stop: make(chan struct{}), + ControllerParallelism: 1, + } +} diff --git a/pkg/inject/run/doc.go b/pkg/inject/run/doc.go new file mode 100644 index 0000000000..9fd0455488 --- /dev/null +++ b/pkg/inject/run/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// The run package contains initialization dependencies for creating controllers and admission webhooks +package run diff --git a/pkg/inject/run/example_args_test.go b/pkg/inject/run/example_args_test.go new file mode 100644 index 0000000000..7b29535a1f --- /dev/null +++ b/pkg/inject/run/example_args_test.go @@ -0,0 +1,29 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package run_test + +import "github.com/kubernetes-sigs/kubebuilder/pkg/inject/run" + +func Example() { + // Create arguments for running Controllers + var _ = run.CreateRunArguments() +} + +func ExampleCreateInjectArgs() { + // Create arguments for running Controllers + var _ = run.CreateRunArguments() +} diff --git a/pkg/install/apiserver_install_strategy.go b/pkg/install/apiserver_install_strategy.go new file mode 100644 index 0000000000..f4bce08ebd --- /dev/null +++ b/pkg/install/apiserver_install_strategy.go @@ -0,0 +1,450 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package install + +import ( + "fmt" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + extensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + apiregistrationv1beta1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1beta1" + "sync" +) + +// ApiserverInstallStrategy installs APIs using apiserver aggregation. Creates a StatefulSet for +// etcd storage. +type ApiserverInstallStrategy struct { + // Name is the name of the installation + Name string + + // ApiserverImage is the container image for the aggregated apiserver + ApiserverImage string + + // ControllerManagerImage is the container image to use for the controller + ControllerManagerImage string + + // DocsImage is the container image to use for hosting reference documentation + DocsImage string + + // APIMeta contains the generated API metadata from the pkg/apis + APIMeta APIMeta + + // Certs are the certs for installing the aggregated apiserver + Certs *Certs + certOnce sync.Once +} + +// GetServiceAccount returns the default ServiceAccount +func (s *ApiserverInstallStrategy) GetServiceAccount() string { + return "default" +} + +// GetCRDs returns the generated CRDs from APIMeta.GetCRDs() +func (s *ApiserverInstallStrategy) GetCRDs() []extensionsv1beta1.CustomResourceDefinition { + return []extensionsv1beta1.CustomResourceDefinition{} +} + +// GetNamespace returns the strategy name suffixed "with -system" +func (s *ApiserverInstallStrategy) GetNamespace() *corev1.Namespace { + ns := &corev1.Namespace{} + ns.Name = fmt.Sprintf("%v-system", s.Name) + return ns +} + +// GetClusterRole returns a ClusterRule with the generated rules by APIMeta.GetPolicyRules +func (s *ApiserverInstallStrategy) GetClusterRole() *rbacv1.ClusterRole { + ns := s.GetNamespace() + role := &rbacv1.ClusterRole{} + role.Namespace = ns.Name + role.Name = fmt.Sprintf("%v-role", ns.Name) + role.Rules = s.APIMeta.GetPolicyRules() + role.Rules = append(role.Rules, + rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"*"}, + Verbs: []string{"*"}, + }) + role.Rules = append(role.Rules, + rbacv1.PolicyRule{ + APIGroups: []string{"authorization.k8s.io"}, + Resources: []string{"*"}, + Verbs: []string{"*"}, + }) + + return role +} + +// GetClusterRoleBinding returns a binding for the ServiceAccount and ClusterRole +func (s *ApiserverInstallStrategy) GetClusterRoleBinding() *rbacv1.ClusterRoleBinding { + rolebinding := &rbacv1.ClusterRoleBinding{} + ns := s.GetNamespace() + rolebinding.Namespace = ns.Name + rolebinding.Name = fmt.Sprintf("%v-rolebinding", rolebinding.Namespace) + + // Bind the Namesapce default ServiceAccount to the system role for the controller + rolebinding.Subjects = []rbacv1.Subject{ + { + Name: s.GetServiceAccount(), + Namespace: ns.Name, + Kind: "ServiceAccount", + }, + } + rolebinding.RoleRef = rbacv1.RoleRef{ + Name: fmt.Sprintf("%v-role", ns.Name), + Kind: "ClusterRole", + APIGroup: "rbac.authorization.k8s.io", + } + return rolebinding +} + +// GetDeployments returns a Deployment to run the Image +func (s *ApiserverInstallStrategy) GetDeployments() []*appsv1.Deployment { + // Controller ControllerManager + controllerManager := &appsv1.Deployment{} + controllerManager.Namespace = fmt.Sprintf("%v-system", s.Name) + controllerManager.Name = fmt.Sprintf("%v-controller-manager", s.Name) + controllerManager.Labels = map[string]string{ + "app": "controller-manager", + "api": s.Name, + } + controllerManager.Spec.Selector = &metav1.LabelSelector{ + MatchLabels: controllerManager.Labels, + } + controllerManager.Spec.Template.Labels = controllerManager.Labels + controllerManager.Spec.Template.Spec.Containers = []corev1.Container{ + { + Name: "controller-manager", + Image: s.ControllerManagerImage, + Command: []string{"/root/controller-manager"}, + Args: []string{"--install-crds=false"}, + Resources: corev1.ResourceRequirements{ + Requests: map[corev1.ResourceName]resource.Quantity{ + "cpu": resource.MustParse("100m"), + "memory": resource.MustParse("20Mi"), + }, + Limits: map[corev1.ResourceName]resource.Quantity{ + "cpu": resource.MustParse("100m"), + "memory": resource.MustParse("30Mi"), + }, + }, + }, + } + + // Apiserver + apiserver := &appsv1.Deployment{} + apiserver.Name = fmt.Sprintf("%v-apiserver", s.Name) + apiserver.Namespace = fmt.Sprintf("%v-system", s.Name) + apiserver.Labels = map[string]string{ + "app": "apiserver", + "api": s.Name, + "apiserver": "true", + } + replicas := int32(1) + apiserver.Spec.Replicas = &replicas + apiserver.Spec.Selector = &metav1.LabelSelector{ + MatchLabels: apiserver.Labels, + } + apiserver.Spec.Template.Labels = apiserver.Labels + apiserver.Spec.Template.Spec.Containers = []corev1.Container{ + { + Name: "apiserver", + Image: s.ApiserverImage, + ImagePullPolicy: "Always", + Command: []string{"/root/apiserver"}, + Args: []string{ + fmt.Sprintf("--etcd-servers=http://%s-etcd:2379", s.Name), + "--tls-cert-file=/apiserver.local.config/certificates/tls.crt", + "--tls-private-key-file=/apiserver.local.config/certificates/tls.key", + "--audit-log-path=-", + "--audit-log-maxage=0", + "--audit-log-maxbackup=0", + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "apiserver-certs", + MountPath: "/apiserver.local.config/certificates", + ReadOnly: true, + }, + }, + Resources: corev1.ResourceRequirements{ + Requests: map[corev1.ResourceName]resource.Quantity{ + "cpu": resource.MustParse("100m"), + "memory": resource.MustParse("20Mi"), + }, + Limits: map[corev1.ResourceName]resource.Quantity{ + "cpu": resource.MustParse("100m"), + "memory": resource.MustParse("30Mi"), + }, + }, + }, + } + apiserver.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "apiserver-certs", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: fmt.Sprintf("apiserver-certs"), + }, + }, + }, + } + deps := []*appsv1.Deployment{controllerManager, apiserver} + if len(s.DocsImage) > 0 { + docs := &appsv1.Deployment{} + docs.Namespace = fmt.Sprintf("%v-system", s.Name) + docs.Name = fmt.Sprintf("%v-docs", s.Name) + docs.Labels = map[string]string{ + "app": "docs", + "api": s.Name, + } + docs.Spec.Selector = &metav1.LabelSelector{ + MatchLabels: docs.Labels, + } + docs.Spec.Template.Labels = docs.Labels + docs.Spec.Template.Spec.Containers = []corev1.Container{ + { + Name: "docs", + Image: s.DocsImage, + }, + } + deps = append(deps, docs) + } + return deps +} + +// GetStatefulSets returns and StatefulSet resource for an etcd instance +func (s *ApiserverInstallStrategy) GetStatefulSets() []*appsv1.StatefulSet { + etcd := &appsv1.StatefulSet{} + etcd.Name = "etcd" + etcd.Namespace = s.GetNamespace().Name + etcd.Spec.ServiceName = "etcd" + etcd.Labels = map[string]string{ + "app": "etcd", + } + replicas := int32(1) + etcd.Spec.Replicas = &replicas + terminationGracePeriodSeconds := int64(10) + etcd.Spec.Selector = &metav1.LabelSelector{ + MatchLabels: etcd.Labels, + } + etcd.Spec.Template.Labels = etcd.Labels + etcd.Spec.Template.Spec.TerminationGracePeriodSeconds = &terminationGracePeriodSeconds + etcd.Spec.Template.Spec.Containers = []corev1.Container{ + { + Name: "etcd", + Image: "quay.io/coreos/etcd:latest", + ImagePullPolicy: "Always", + Resources: corev1.ResourceRequirements{ + Requests: map[corev1.ResourceName]resource.Quantity{ + "cpu": resource.MustParse("100m"), + "memory": resource.MustParse("20Mi"), + }, + Limits: map[corev1.ResourceName]resource.Quantity{ + "cpu": resource.MustParse("100m"), + "memory": resource.MustParse("30Mi"), + }, + }, + Env: []corev1.EnvVar{ + { + Name: "ETCD_DATA_DIR", + Value: "/etcd-data-dir", + }, + }, + Command: []string{ + "/usr/local/bin/etcd", + }, + Args: []string{ + "--listen-client-urls=http://0.0.0.0:2379", + "--advertise-client-urls=http://localhost:2379", + }, + Ports: []corev1.ContainerPort{ + { + ContainerPort: 2379, + }, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "etcd-data-dir", + MountPath: "/etcd-data-dir", + }, + }, + ReadinessProbe: &corev1.Probe{ + Handler: corev1.Handler{ + HTTPGet: &corev1.HTTPGetAction{ + Port: intstr.FromInt(2379), + Path: "/health", + }, + }, + FailureThreshold: 1, + InitialDelaySeconds: 10, + PeriodSeconds: 10, + SuccessThreshold: 1, + TimeoutSeconds: 2, + }, + LivenessProbe: &corev1.Probe{ + Handler: corev1.Handler{ + HTTPGet: &corev1.HTTPGetAction{ + Port: intstr.FromInt(2379), + Path: "/health", + }, + }, + FailureThreshold: 3, + InitialDelaySeconds: 10, + PeriodSeconds: 10, + SuccessThreshold: 1, + TimeoutSeconds: 2, + }, + }, + } + etcd.Spec.VolumeClaimTemplates = []corev1.PersistentVolumeClaim{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "etcd-data-dir", + Annotations: map[string]string{ + "volume.beta.kubernetes.io/storage-class": "standard", + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{"ReadWriteOnce"}, + Resources: corev1.ResourceRequirements{ + Requests: map[corev1.ResourceName]resource.Quantity{ + "storage": resource.MustParse("10Gi"), + }, + }, + }, + }, + } + return []*appsv1.StatefulSet{etcd} +} + +func (s *ApiserverInstallStrategy) GetServices() []*corev1.Service { + ns := s.GetNamespace().Name + etcdService := &corev1.Service{} + etcdService.Name = fmt.Sprintf("%s-etcd", s.Name) + etcdService.Namespace = ns + etcdService.Labels = map[string]string{ + "app": "etcd", + } + etcdService.Spec.Ports = []corev1.ServicePort{ + { + Name: "etcd", + Port: 2379, + Protocol: corev1.ProtocolTCP, + TargetPort: intstr.FromInt(2379), + }, + } + etcdService.Spec.Selector = map[string]string{ + "app": "etcd", + } + + apiserverService := &corev1.Service{} + apiserverService.Name = fmt.Sprintf("%s-apiserver", s.Name) + apiserverService.Namespace = ns + apiserverService.Labels = map[string]string{ + "apiserver": "true", + } + apiserverService.Spec.Ports = []corev1.ServicePort{ + { + Name: "apiserver", + Port: 443, + Protocol: corev1.ProtocolTCP, + TargetPort: intstr.FromInt(443), + }, + } + apiserverService.Spec.Selector = map[string]string{ + "apiserver": "true", + "app": "apiserver", + "api": s.Name, + } + services := []*corev1.Service{etcdService, apiserverService} + + if len(s.DocsImage) > 0 { + docsService := &corev1.Service{} + docsService.Name = fmt.Sprintf("%s-docs", s.Name) + docsService.Namespace = ns + docsService.Labels = map[string]string{ + "app": "docs", + } + docsService.Spec.Ports = []corev1.ServicePort{ + { + Name: "docs", + Port: 80, + Protocol: corev1.ProtocolTCP, + TargetPort: intstr.FromInt(80), + }, + } + docsService.Spec.Selector = map[string]string{ + "app": "docs", + } + services = append(services, docsService) + } + + return services +} + +func (s *ApiserverInstallStrategy) GetAPIServices() []*apiregistrationv1beta1.APIService { + s.initCert() + ns := s.GetNamespace() + result := []*apiregistrationv1beta1.APIService{} + for _, gv := range s.APIMeta.GetGroupVersions() { + a := &apiregistrationv1beta1.APIService{} + a.Name = gv.Version + "." + gv.Group + a.Labels = map[string]string{ + "api": s.Name, + "apiserver": "true", + } + a.Spec.Group = gv.Group + a.Spec.Version = gv.Version + a.Spec.GroupPriorityMinimum = 2000 + a.Spec.VersionPriority = 10 + a.Spec.Service = &apiregistrationv1beta1.ServiceReference{ + Name: fmt.Sprintf("%s-apiserver", s.Name), + Namespace: ns.Name, + } + a.Spec.CABundle = s.Certs.CACrt + result = append(result, a) + } + return result +} + +func (s *ApiserverInstallStrategy) initCert() { + s.certOnce.Do(func() { + srv := fmt.Sprintf("%s-apiserver", s.Name) + ns := s.GetNamespace().Name + s.Certs = CreateCerts(srv, ns) + }) +} + +func (s *ApiserverInstallStrategy) GetSecrets() []*corev1.Secret { + s.initCert() + tls := &corev1.Secret{ + Data: map[string][]byte{ + "tls.crt": s.Certs.ClientCrt, + "tls.key": s.Certs.ClientKey, + }} + tls.Name = "apiserver-certs" + tls.Namespace = s.GetNamespace().Name + return []*corev1.Secret{tls} +} +func (s *ApiserverInstallStrategy) GetConfigMaps() []*corev1.ConfigMap { return nil } +func (s *ApiserverInstallStrategy) BeforeInstall() error { return nil } +func (s *ApiserverInstallStrategy) AfterInstall() error { return nil } diff --git a/pkg/install/cert_provider.go b/pkg/install/cert_provider.go new file mode 100644 index 0000000000..31b1d28e62 --- /dev/null +++ b/pkg/install/cert_provider.go @@ -0,0 +1,174 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package install + +import ( + //"bytes" + //"crypto/rand" + //"crypto/rsa" + //"crypto/x509" + //"crypto/x509/pkix" + //"encoding/pem" + "fmt" + "io/ioutil" + "log" + //"math/big" + //"net" + "os" + "os/exec" + "path/filepath" + "strings" + //"time" +) + +// Certs contains the certificate information for installing APIs +type Certs struct { + // ClientKey is the client private key + ClientKey []byte + + // CACrt is the public CA certificate + CACrt []byte + + // ClientCrt is the public client certificate + ClientCrt []byte +} + +// CreateCerts creates Certs to be used for registering webhooks and extension apis in a +// Kubernetes apiserver +func CreateCerts(serviceName, serviceNamespace string) *Certs { + dir := os.TempDir() + + openssl("req", "-x509", + "-newkey", "rsa:2048", + "-keyout", filepath.Join(dir, "apiserver_ca.key"), + "-out", filepath.Join(dir, "apiserver_ca.crt"), + "-days", "365", + "-nodes", + "-subj", fmt.Sprintf("/C=un/ST=st/L=l/O=o/OU=ou/CN=%s-certificate-authority", serviceName), + ) + + // Use ..svc as the domain Name for the certificate + openssl("req", + "-out", filepath.Join(dir, "apiserver.csr"), + "-new", + "-newkey", "rsa:2048", + "-nodes", + "-keyout", filepath.Join(dir, "apiserver.key"), + "-subj", fmt.Sprintf("/C=un/ST=st/L=l/O=o/OU=ou/CN=%s.%s.svc", serviceName, serviceNamespace), + ) + + openssl("x509", "-req", + "-days", "365", + "-in", filepath.Join(dir, "apiserver.csr"), + "-CA", filepath.Join(dir, "apiserver_ca.crt"), + "-CAkey", filepath.Join(dir, "apiserver_ca.key"), + "-CAcreateserial", + "-out", filepath.Join(dir, "apiserver.crt"), + ) + cert := &Certs{} + var err error + cert.CACrt, err = ioutil.ReadFile(filepath.Join(dir, "apiserver_ca.crt")) + if err != nil { + log.Fatalf("read %s failed %v", filepath.Join(dir, "apiserver_ca.crt"), err) + } + cert.ClientKey, err = ioutil.ReadFile(filepath.Join(dir, "apiserver.key")) + if err != nil { + log.Fatalf("read %s failed %v", filepath.Join(dir, "apiserver.key"), err) + } + cert.ClientCrt, err = ioutil.ReadFile(filepath.Join(dir, "apiserver.crt")) + if err != nil { + log.Fatalf("read %s failed %v", filepath.Join(dir, "apiserver.crt"), err) + } + return cert +} + +func openssl(args ...string) { + c := exec.Command("openssl", args...) + c.Stderr = os.Stderr + c.Stdout = os.Stdout + log.Printf("%s\n", strings.Join(c.Args, " ")) + err := c.Run() + if err != nil { + log.Fatalf("command failed %v", err) + } +} + +//type Cert struct { +// Email string +// DNS string +// Org string +// Hosts []string +//} +// +//func (c Cert) Create() ([]byte, []byte) { +// // yesterday +// notBefore := time.Now().AddDate(0, 0, -1) +// // 1 year from yesterday +// notAfter := notBefore.Add(time.Hour * 24 * 365) +// +// priv, err := rsa.GenerateKey(rand.Reader, 2048) +// +// serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) +// if err != nil { +// log.Fatalf("failed to generate serial number: %s", err) +// } +// +// // Create the cert template +// template := x509.Certificate{ +// SerialNumber: serialNumber, +// Subject: pkix.Name{ +// Organization: []string{c.Org}, +// }, +// NotBefore: notBefore, +// NotAfter: notAfter, +// +// KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, +// ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, +// BasicConstraintsValid: true, +// IsCA: true, +// } +// template.DNSNames = append(template.DNSNames, c.DNS) +// template.EmailAddresses = append(template.EmailAddresses, c.Email) +// template.KeyUsage |= x509.KeyUsageCertSign +// for _, h := range c.Hosts { +// if ip := net.ParseIP(h); ip != nil { +// template.IPAddresses = append(template.IPAddresses, ip) +// } else { +// template.DNSNames = append(template.DNSNames, h) +// } +// } +// +// // Create the certificate +// derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) +// if err != nil { +// log.Fatalf("Failed to create certificate: %s", err) +// } +// +// pemBytes := &bytes.Buffer{} +// err = pem.Encode(pemBytes, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) +// if err != nil { +// log.Fatalf("failed to encode certificate: %v", err) +// } +// +// keyBytes := &bytes.Buffer{} +// err = pem.Encode(keyBytes, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) +// if err != nil { +// log.Fatalf("failed to encode certificate: %v", err) +// } +// +// return pemBytes.Bytes(), keyBytes.Bytes() +//} diff --git a/pkg/install/crd_install_strategy.go b/pkg/install/crd_install_strategy.go new file mode 100644 index 0000000000..443d0e296c --- /dev/null +++ b/pkg/install/crd_install_strategy.go @@ -0,0 +1,182 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package install + +import ( + "fmt" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + extensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + apiregistrationv1beta1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1beta1" +) + +// CRDInstallStrategy installs APIs into a cluster using CRDs +type CRDInstallStrategy struct { + // Name is the installation + Name string + + // ControllerManagerImage is the container image to use for the controller + ControllerManagerImage string + + // DocsImage is the container image to use for hosting reference documentation + DocsImage string + + // APIMeta contains the generated API metadata from the pkg/apis + APIMeta APIMeta +} + +// GetServiceAccount returns the default ServiceAccount +func (s *CRDInstallStrategy) GetServiceAccount() string { + return "default" +} + +// GetCRDs returns the generated CRDs from APIMeta.GetCRDs() +func (s *CRDInstallStrategy) GetCRDs() []*extensionsv1beta1.CustomResourceDefinition { + return s.APIMeta.GetCRDs() +} + +// GetNamespace returns the strategy name suffixed "with -system" +func (s *CRDInstallStrategy) GetNamespace() *corev1.Namespace { + ns := &corev1.Namespace{} + ns.Name = fmt.Sprintf("%v-system", s.Name) + return ns +} + +// GetClusterRole returns a ClusterRule with the generated rules by APIMeta.GetPolicyRules +func (s *CRDInstallStrategy) GetClusterRole() *rbacv1.ClusterRole { + ns := s.GetNamespace() + role := &rbacv1.ClusterRole{} + role.Namespace = ns.Name + role.Name = fmt.Sprintf("%v-role", ns.Name) + role.Rules = s.APIMeta.GetPolicyRules() + return role +} + +// GetClusterRoleBinding returns a binding for the ServiceAccount and ClusterRole +func (s *CRDInstallStrategy) GetClusterRoleBinding() *rbacv1.ClusterRoleBinding { + rolebinding := &rbacv1.ClusterRoleBinding{} + ns := s.GetNamespace() + rolebinding.Namespace = ns.Name + rolebinding.Name = fmt.Sprintf("%v-rolebinding", rolebinding.Namespace) + + // Bind the Namesapce default ServiceAccount to the system role for the controller + rolebinding.Subjects = []rbacv1.Subject{ + { + Name: s.GetServiceAccount(), + Namespace: ns.Name, + Kind: "ServiceAccount", + }, + } + rolebinding.RoleRef = rbacv1.RoleRef{ + Name: fmt.Sprintf("%v-role", ns.Name), + Kind: "ClusterRole", + APIGroup: "rbac.authorization.k8s.io", + } + return rolebinding +} + +// GetDeployments returns a Deployment to run the Image +func (s *CRDInstallStrategy) GetDeployments() []*appsv1.Deployment { + controllerManager := &appsv1.Deployment{} + controllerManager.Namespace = fmt.Sprintf("%v-system", s.Name) + controllerManager.Name = fmt.Sprintf("%v-controller-manager", s.Name) + controllerManager.Labels = map[string]string{ + "app": "controller-manager", + "api": s.Name, + } + controllerManager.Spec.Selector = &metav1.LabelSelector{ + MatchLabels: controllerManager.Labels, + } + controllerManager.Spec.Template.Labels = controllerManager.Labels + controllerManager.Spec.Template.Spec.Containers = []corev1.Container{ + { + Name: "controller-manager", + Image: s.ControllerManagerImage, + Command: []string{"/root/controller-manager"}, + Args: []string{"--install-crds=false"}, + Resources: corev1.ResourceRequirements{ + Requests: map[corev1.ResourceName]resource.Quantity{ + "cpu": resource.MustParse("100m"), + "memory": resource.MustParse("20Mi"), + }, + Limits: map[corev1.ResourceName]resource.Quantity{ + "cpu": resource.MustParse("100m"), + "memory": resource.MustParse("30Mi"), + }, + }, + }, + } + + deps := []*appsv1.Deployment{controllerManager} + if len(s.DocsImage) > 0 { + docs := &appsv1.Deployment{} + docs.Namespace = fmt.Sprintf("%v-system", s.Name) + docs.Name = fmt.Sprintf("%v-docs", s.Name) + docs.Labels = map[string]string{ + "app": "docs", + "api": s.Name, + } + docs.Spec.Selector = &metav1.LabelSelector{ + MatchLabels: docs.Labels, + } + docs.Spec.Template.Labels = docs.Labels + docs.Spec.Template.Spec.Containers = []corev1.Container{ + { + Name: "docs", + Image: s.DocsImage, + }, + } + deps = append(deps, docs) + } + + return deps +} + +func (s *CRDInstallStrategy) GetServices() []*corev1.Service { + ns := s.GetNamespace().Name + docsService := &corev1.Service{} + docsService.Name = fmt.Sprintf("%s-docs", s.Name) + docsService.Namespace = ns + docsService.Labels = map[string]string{ + "app": "docs", + } + docsService.Spec.Ports = []corev1.ServicePort{ + { + Name: "docs", + Port: 80, + Protocol: corev1.ProtocolTCP, + TargetPort: intstr.FromInt(80), + }, + } + docsService.Spec.Selector = map[string]string{ + "app": "docs", + } + + return []*corev1.Service{docsService} +} + +func (s *CRDInstallStrategy) GetSecrets() []*corev1.Secret { return nil } +func (s *CRDInstallStrategy) GetConfigMaps() []*corev1.ConfigMap { return nil } +func (s *CRDInstallStrategy) GetStatefulSets() []*appsv1.StatefulSet { return nil } +func (s *CRDInstallStrategy) BeforeInstall() error { return nil } +func (s *CRDInstallStrategy) AfterInstall() error { return nil } +func (s *CRDInstallStrategy) GetAPIServices() []*apiregistrationv1beta1.APIService { return nil } diff --git a/pkg/install/doc.go b/pkg/install/doc.go new file mode 100644 index 0000000000..0ae0eacb59 --- /dev/null +++ b/pkg/install/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// The install package contains strategies for installing controllers and admission webhooks into Kubernetes clusters. +package install diff --git a/pkg/install/install_strategy.go b/pkg/install/install_strategy.go new file mode 100644 index 0000000000..2cc8d0f2a8 --- /dev/null +++ b/pkg/install/install_strategy.go @@ -0,0 +1,104 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package install + +import ( + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + extensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + "k8s.io/apimachinery/pkg/runtime/schema" + apiregistrationv1beta1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1beta1" +) + +// DefaultInstallStrategy is the default install strategy to use +type DefaultInstallStrategy = CRDInstallStrategy + +// InstallStrategy defines what resources should be created as part of installing +// an API extension. +type InstallStrategy interface { + // GetCRDs returns a list of CRDs to create + GetCRDs() []*extensionsv1beta1.CustomResourceDefinition + + // GetNamespace returns the namespace to install the controller-manager into. + GetNamespace() *corev1.Namespace + + // GetServiceAccount returns the name of the ServiceAccount to use + GetServiceAccount() string + + // GetClusterRole returns a ClusterRole to create + GetClusterRole() *rbacv1.ClusterRole + + // GetClusterRoleBinding returns a GetClusterRoleBinding to create + GetClusterRoleBinding() *rbacv1.ClusterRoleBinding + + // GetDeployments returns the Deployments to create + GetDeployments() []*appsv1.Deployment + + // GetStatefulSets the StatefulSets to create + GetStatefulSets() []*appsv1.StatefulSet + + // GetSecrets returns the Secrets to create + GetSecrets() []*corev1.Secret + + // GetConfigMaps returns the ConfigMaps to create + GetConfigMaps() []*corev1.ConfigMap + + // GetServices returns the Services to create + GetServices() []*corev1.Service + + // GetAPIServices returns the APIServices to create + GetAPIServices() []*apiregistrationv1beta1.APIService + + // BeforeInstall is run before installing other components + BeforeInstall() error + + // AfterInstall is run after installing other components + AfterInstall() error +} + +// EmptyInstallStrategy is a Strategy that doesn't create anything but can be embedded in +// another InstallStrategy. +type EmptyInstallStrategy struct{} + +func (s EmptyInstallStrategy) AfterInstall() error { return nil } +func (s EmptyInstallStrategy) BeforeInstall() error { return nil } +func (s EmptyInstallStrategy) GetAPIServices() []*apiregistrationv1beta1.APIService { return nil } +func (s EmptyInstallStrategy) GetClusterRole() *rbacv1.ClusterRole { return nil } +func (s EmptyInstallStrategy) GetClusterRoleBinding() *rbacv1.ClusterRoleBinding { return nil } +func (s EmptyInstallStrategy) GetConfigMaps() []*corev1.ConfigMap { return nil } +func (s EmptyInstallStrategy) GetCRDs() []*extensionsv1beta1.CustomResourceDefinition { + return []*extensionsv1beta1.CustomResourceDefinition{} +} +func (s EmptyInstallStrategy) GetDeployments() []*appsv1.Deployment { return nil } +func (s EmptyInstallStrategy) GetNamespace() *corev1.Namespace { return nil } +func (s EmptyInstallStrategy) GetSecrets() []*corev1.Secret { return nil } +func (s EmptyInstallStrategy) GetServiceAccount() string { return "" } +func (s EmptyInstallStrategy) GetServices() []*corev1.Service { return nil } +func (s EmptyInstallStrategy) GetStatefulSets() []*appsv1.StatefulSet { return nil } + +// APIMeta returns metadata about the APIs +type APIMeta interface { + // GetCRDs returns the CRDs + GetCRDs() []*extensionsv1beta1.CustomResourceDefinition + + // GetPolicyRules returns the PolicyRules to apply to the ServiceAccount running the controller + GetPolicyRules() []rbacv1.PolicyRule + + // GetGroupVersions returns the GroupVersions of the CRDs or aggregated APIs + GetGroupVersions() []schema.GroupVersion +} diff --git a/pkg/install/installer.go b/pkg/install/installer.go new file mode 100644 index 0000000000..1e3189ecb0 --- /dev/null +++ b/pkg/install/installer.go @@ -0,0 +1,292 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package install + +import ( + "log" + "reflect" + "time" + + apiextv1beta1client "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1beta1" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + appsv1client "k8s.io/client-go/kubernetes/typed/apps/v1" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" + rbacv1client "k8s.io/client-go/kubernetes/typed/rbac/v1" + "k8s.io/client-go/rest" + apiregistrationv1beta1 "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/typed/apiregistration/v1beta1" +) + +type installer struct { + rbac rbacv1client.RbacV1Interface + core corev1client.CoreV1Interface + apiext apiextv1beta1client.CustomResourceDefinitionInterface + apps appsv1client.AppsV1Interface + apireg apiregistrationv1beta1.APIServiceInterface +} + +// NewInstaller returns a new installer +func NewInstaller(config *rest.Config) *installer { + cs := kubernetes.NewForConfigOrDie(config) + ae := apiextv1beta1client.NewForConfigOrDie(config) + ar := apiregistrationv1beta1.NewForConfigOrDie(config) + return &installer{ + cs.RbacV1(), + cs.CoreV1(), + ae.CustomResourceDefinitions(), + cs.AppsV1(), + ar.APIServices(), + } +} + +// Install installs the components provided by the InstallStrategy +func (i *installer) Install(strategy InstallStrategy) error { + if err := strategy.BeforeInstall(); err != nil { + return err + } + if err := i.installCrds(strategy); err != nil { + return err + } + if err := i.installNamespace(strategy); err != nil { + return err + } + if err := i.installServiceAccount(strategy); err != nil { + return err + } + if err := i.installClusterRole(strategy); err != nil { + return err + } + if err := i.installClusterRoleBinding(strategy); err != nil { + return err + } + if err := i.installSecrets(strategy); err != nil { + return err + } + if err := i.installConfigMaps(strategy); err != nil { + return err + } + if err := i.installDeployments(strategy); err != nil { + return err + } + if err := i.installStatefulSets(strategy); err != nil { + return err + } + if err := i.installAPIServices(strategy); err != nil { + return err + } + if err := i.installServices(strategy); err != nil { + return err + } + if err := strategy.AfterInstall(); err != nil { + return err + } + log.Printf("Finished installing.") + return nil +} + +func (i *installer) installCrds(strategy InstallStrategy) error { + if len(strategy.GetCRDs()) == 0 { + return nil + } + for _, crd := range strategy.GetCRDs() { + value, err := i.apiext.Get(crd.Name, v1.GetOptions{}) + // Create case + if err != nil || value == nil { + log.Printf("Creating CRD %v\n", crd.Name) + _, err = i.apiext.Create(crd) + if err != nil { + return err + } + continue + } + // Update case + if !reflect.DeepEqual(value.Spec, crd.Spec) { + log.Printf("Updating CRD %v\n", crd.Name) + value.Spec = crd.Spec + _, err = i.apiext.Update(value) + if err != nil { + return err + } + continue + } + } + return nil +} + +func (i *installer) installNamespace(strategy InstallStrategy) error { + ns := strategy.GetNamespace() + if ns == nil { + return nil + } + if foundNs, err := i.core.Namespaces().Get(ns.Name, v1.GetOptions{}); err != nil || foundNs == nil { + log.Printf("Creating Namespace %v\n", ns.Name) + _, err = i.core.Namespaces().Create(ns) + return err + } + return nil +} + +func (i *installer) installServiceAccount(strategy InstallStrategy) error { + accountName := strategy.GetServiceAccount() + if len(accountName) == 0 { + return nil + } + ns := strategy.GetNamespace() + var ret error + // Wait for the namespace SA to be created + for cnt := 0; cnt < 5; cnt++ { + sa, err := i.core.ServiceAccounts(ns.Name).Get(accountName, v1.GetOptions{}) + ret = err + if err == nil || sa != nil { + break + } + time.Sleep(time.Second * 2) + } + return ret +} + +func (i *installer) installClusterRole(strategy InstallStrategy) error { + role := strategy.GetClusterRole() + if role == nil { + return nil + } + // Create case + if foundRole, err := i.rbac.ClusterRoles().Get(role.Name, v1.GetOptions{}); err == nil && foundRole != nil { + log.Printf("Updating ClusterRole %v\n", role.Name) + foundRole.Rules = role.Rules + _, err = i.rbac.ClusterRoles().Update(foundRole) + return err + } + // Update case + log.Printf("Creating ClusterRole %v\n", role.Name) + _, err := i.rbac.ClusterRoles().Create(role) + return err +} + +func (i *installer) installClusterRoleBinding(strategy InstallStrategy) error { + rolebinding := strategy.GetClusterRoleBinding() + if rolebinding == nil { + return nil + } + if foundBinding, err := i.rbac.ClusterRoleBindings().Get(rolebinding.Name, v1.GetOptions{}); err != nil || foundBinding == nil { + log.Printf("Creating ClusterRoleBinding %v\n", rolebinding.Name) + _, err = i.rbac.ClusterRoleBindings().Create(rolebinding) + return err + } + return nil +} + +func (i *installer) installDeployments(strategy InstallStrategy) error { + ns := strategy.GetNamespace() + for _, deployment := range strategy.GetDeployments() { + if foundDeployment, err := i.apps.Deployments(ns.Name).Get(deployment.Name, v1.GetOptions{}); err == nil && foundDeployment != nil { + log.Printf("Updating Deployment %v\n", deployment.Name) + foundDeployment.Spec = deployment.Spec + _, err := i.apps.Deployments(ns.Name).Update(foundDeployment) + return err + } + log.Printf("Creating Deployment %v\n", deployment.Name) + if _, err := i.apps.Deployments(ns.Name).Create(deployment); err != nil { + return err + } + } + return nil +} + +func (i *installer) installStatefulSets(strategy InstallStrategy) error { + ns := strategy.GetNamespace() + for _, statefulSet := range strategy.GetStatefulSets() { + if found, err := i.apps.StatefulSets(ns.Name).Get(statefulSet.Name, v1.GetOptions{}); err == nil && found != nil { + log.Printf("Updating StatefulSet %v\n", statefulSet.Name) + found.Spec = statefulSet.Spec + _, err := i.apps.StatefulSets(ns.Name).Update(found) + return err + } + log.Printf("Creating StatefulSet %v\n", statefulSet.Name) + if _, err := i.apps.StatefulSets(ns.Name).Create(statefulSet); err != nil { + return err + } + } + return nil +} + +func (i *installer) installServices(strategy InstallStrategy) error { + ns := strategy.GetNamespace() + for _, service := range strategy.GetServices() { + if found, err := i.core.Services(ns.Name).Get(service.Name, v1.GetOptions{}); err == nil && found != nil { + log.Printf("Updating Service %v\n", service.Name) + found.Spec = service.Spec + _, err := i.core.Services(ns.Name).Update(found) + return err + } + log.Printf("Creating Service %v\n", service.Name) + if _, err := i.core.Services(ns.Name).Create(service); err != nil { + return err + } + } + return nil +} + +func (i *installer) installSecrets(strategy InstallStrategy) error { + ns := strategy.GetNamespace() + for _, secret := range strategy.GetSecrets() { + if found, err := i.core.Secrets(ns.Name).Get(secret.Name, v1.GetOptions{}); err == nil && found != nil { + log.Printf("Updating Secret %v\n", secret.Name) + found.Data = secret.Data + _, err := i.core.Secrets(ns.Name).Update(found) + return err + } + log.Printf("Creating Secret %v\n", secret.Name) + if _, err := i.core.Secrets(ns.Name).Create(secret); err != nil { + return err + } + } + return nil +} + +func (i *installer) installConfigMaps(strategy InstallStrategy) error { + ns := strategy.GetNamespace() + for _, configmap := range strategy.GetConfigMaps() { + if found, err := i.core.ConfigMaps(ns.Name).Get(configmap.Name, v1.GetOptions{}); err == nil && found != nil { + log.Printf("Updating ConfigMap %v\n", configmap.Name) + found.Data = configmap.Data + _, err := i.core.ConfigMaps(ns.Name).Update(found) + return err + } + log.Printf("Creating ConfigMap %v\n", configmap.Name) + if _, err := i.core.ConfigMaps(ns.Name).Create(configmap); err != nil { + return err + } + } + return nil +} + +func (i *installer) installAPIServices(strategy InstallStrategy) error { + for _, apiservice := range strategy.GetAPIServices() { + if found, err := i.apireg.Get(apiservice.Name, v1.GetOptions{}); err == nil && found != nil { + log.Printf("Updating ApiService %v\n", apiservice.Name) + found.Spec = apiservice.Spec + _, err := i.apireg.Update(found) + return err + } + log.Printf("Creating ApiService %v\n", apiservice.Name) + if _, err := i.apireg.Create(apiservice); err != nil { + return err + } + } + return nil +} diff --git a/pkg/install/uninstaller.go b/pkg/install/uninstaller.go new file mode 100644 index 0000000000..14ba717fb2 --- /dev/null +++ b/pkg/install/uninstaller.go @@ -0,0 +1,132 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package install + +import ( + "log" + + apiextv1beta1client "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1beta1" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + appsv1client "k8s.io/client-go/kubernetes/typed/apps/v1" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" + rbacv1client "k8s.io/client-go/kubernetes/typed/rbac/v1" + "k8s.io/client-go/rest" + apiregistrationv1beta1 "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/typed/apiregistration/v1beta1" +) + +type uninstaller struct { + rbac rbacv1client.RbacV1Interface + core corev1client.CoreV1Interface + apiext apiextv1beta1client.CustomResourceDefinitionInterface + apps appsv1client.AppsV1Interface + apireg apiregistrationv1beta1.APIServiceInterface +} + +// NewUninstaller returns a new uninstaller +func NewUninstaller(config *rest.Config) *uninstaller { + cs := kubernetes.NewForConfigOrDie(config) + ae := apiextv1beta1client.NewForConfigOrDie(config) + ar := apiregistrationv1beta1.NewForConfigOrDie(config) + return &uninstaller{ + cs.RbacV1(), + cs.CoreV1(), + ae.CustomResourceDefinitions(), + cs.AppsV1(), + ar.APIServices(), + } +} + +// Uninstall uninstalls the components installed by the InstallStrategy +func (i *uninstaller) Uninstall(strategy InstallStrategy) error { + if err := i.uninstallNamespace(strategy); err != nil { + return err + } + if err := i.uninstallClusterRole(strategy); err != nil { + return err + } + if err := i.uninstallClusterRoleBinding(strategy); err != nil { + return err + } + if err := i.uninstallCrds(strategy); err != nil { + return err + } + if err := i.uninstallAPIServices(strategy); err != nil { + return err + } + log.Printf("Finished uninstalling.") + return nil +} + +func (i *uninstaller) uninstallCrds(strategy InstallStrategy) error { + if len(strategy.GetCRDs()) == 0 { + return nil + } + for _, crd := range strategy.GetCRDs() { + value, err := i.apiext.Get(crd.Name, v1.GetOptions{}) + if err == nil && value != nil { + if err = i.apiext.Delete(crd.Name, &v1.DeleteOptions{}); err != nil { + return err + } + } + } + return nil +} + +func (i *uninstaller) uninstallNamespace(strategy InstallStrategy) error { + ns := strategy.GetNamespace() + if ns == nil { + return nil + } + if foundNs, err := i.core.Namespaces().Get(ns.Name, v1.GetOptions{}); err == nil && foundNs != nil { + return i.core.Namespaces().Delete(ns.Name, &v1.DeleteOptions{}) + } + return nil +} + +func (i *uninstaller) uninstallClusterRole(strategy InstallStrategy) error { + role := strategy.GetClusterRole() + if role == nil { + return nil + } + if foundRole, err := i.rbac.ClusterRoles().Get(role.Name, v1.GetOptions{}); err == nil && foundRole != nil { + return i.rbac.ClusterRoles().Delete(foundRole.Name, &v1.DeleteOptions{}) + } + return nil +} + +func (i *uninstaller) uninstallClusterRoleBinding(strategy InstallStrategy) error { + rolebinding := strategy.GetClusterRoleBinding() + if rolebinding == nil { + return nil + } + if foundBinding, err := i.rbac.ClusterRoleBindings().Get(rolebinding.Name, v1.GetOptions{}); err == nil && foundBinding != nil { + return i.rbac.ClusterRoleBindings().Delete(rolebinding.Name, &v1.DeleteOptions{}) + } + return nil +} + +func (i *uninstaller) uninstallAPIServices(strategy InstallStrategy) error { + for _, apiservice := range strategy.GetAPIServices() { + if found, err := i.apireg.Get(apiservice.Name, v1.GetOptions{}); err == nil && found != nil { + if err = i.apireg.Delete(found.Name, &v1.DeleteOptions{}); err != nil { + return err + } + } + } + return nil +} diff --git a/pkg/internal/admission/decode.go b/pkg/internal/admission/decode.go new file mode 100644 index 0000000000..d43927cfc9 --- /dev/null +++ b/pkg/internal/admission/decode.go @@ -0,0 +1,47 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admission + +import ( + "fmt" + "k8s.io/api/admission/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var ( + scheme = runtime.NewScheme() + codecs = serializer.NewCodecFactory(scheme) +) + +// Decode reads the Raw data from review and deserializes it into object returning a non-nil reponse if there was an +// error +func Decode(review v1beta1.AdmissionReview, object runtime.Object, + resourceType metav1.GroupVersionResource) *v1beta1.AdmissionResponse { + if review.Request.Resource != resourceType { + return ErrorResponse(fmt.Errorf("expect resource to be %s", resourceType)) + } + + raw := review.Request.Object.Raw + deserializer := codecs.UniversalDeserializer() + if _, _, err := deserializer.Decode(raw, nil, object); err != nil { + fmt.Printf("%v", err) + return ErrorResponse(err) + } + return nil +} diff --git a/pkg/internal/admission/doc.go b/pkg/internal/admission/doc.go new file mode 100644 index 0000000000..ea6c137c24 --- /dev/null +++ b/pkg/internal/admission/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// The admission package provides libraries for creating admission webhooks. +package admission diff --git a/pkg/internal/admission/example_admissionfunc_test.go b/pkg/internal/admission/example_admissionfunc_test.go new file mode 100644 index 0000000000..788cad258c --- /dev/null +++ b/pkg/internal/admission/example_admissionfunc_test.go @@ -0,0 +1,42 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admission_test + +import ( + "fmt" + + "github.com/kubernetes-sigs/kubebuilder/pkg/internal/admission" + "k8s.io/api/admission/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func ExampleAdmissionFunc() { + var _ admission.AdmissionFunc = func(review v1beta1.AdmissionReview) *v1beta1.AdmissionResponse { + pod := corev1.Pod{} + resourceType := metav1.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} + if errResp := admission.Decode(review, &pod, resourceType); errResp != nil { + return errResp + } + // Business logic for admission decision + if len(pod.Spec.Containers) != 1 { + return admission.DenyResponse(fmt.Sprintf( + "pod %s/%s may only have 1 container.", pod.Namespace, pod.Name)) + } + return admission.AllowResponse() + } +} diff --git a/pkg/internal/admission/example_decode_test.go b/pkg/internal/admission/example_decode_test.go new file mode 100644 index 0000000000..368f0bacf7 --- /dev/null +++ b/pkg/internal/admission/example_decode_test.go @@ -0,0 +1,46 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admission_test + +import ( + "fmt" + "github.com/kubernetes-sigs/kubebuilder/pkg/internal/admission" + "k8s.io/api/admission/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func ExampleDecode() { + var review v1beta1.AdmissionReview + resourceType := metav1.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} + pod := corev1.Pod{} + if errResp := admission.Decode(review, &pod, resourceType); errResp != nil { + // Send error resp + } +} + +func ExampleErrorResponse() { + admission.ErrorResponse(fmt.Errorf("some error explanation")) +} + +func ExampleDenyResponse() { + admission.DenyResponse(fmt.Sprintf("some deny explanation")) +} + +func ExampleAllowResponse() { + admission.AllowResponse() +} diff --git a/pkg/internal/admission/example_handlefunc_test.go b/pkg/internal/admission/example_handlefunc_test.go new file mode 100644 index 0000000000..fe26dd1dd9 --- /dev/null +++ b/pkg/internal/admission/example_handlefunc_test.go @@ -0,0 +1,59 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admission_test + +import ( + "fmt" + + "github.com/kubernetes-sigs/kubebuilder/pkg/internal/admission" + "k8s.io/api/admission/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func ExampleHandleFunc() { + resourceType := metav1.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} + admission.HandleFunc("/pod", resourceType, func(review v1beta1.AdmissionReview) *v1beta1.AdmissionResponse { + pod := corev1.Pod{} + if errResp := admission.Decode(review, &pod, resourceType); errResp != nil { + return errResp + } + // Business logic for admission decision + if len(pod.Spec.Containers) != 1 { + return admission.DenyResponse(fmt.Sprintf( + "pod %s/%s may only have 1 container.", pod.Namespace, pod.Name)) + } + return admission.AllowResponse() + }) +} + +func ExampleAdmissionHandler_HandleFunc() { + resourceType := metav1.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} + ah := admission.AdmissionManager{} + ah.HandleFunc("/pod", resourceType, func(review v1beta1.AdmissionReview) *v1beta1.AdmissionResponse { + pod := corev1.Pod{} + if errResp := admission.Decode(review, &pod, resourceType); errResp != nil { + return errResp + } + // Business logic for admission decision + if len(pod.Spec.Containers) != 1 { + return admission.DenyResponse(fmt.Sprintf( + "pod %s/%s may only have 1 container.", pod.Namespace, pod.Name)) + } + return admission.AllowResponse() + }) +} diff --git a/pkg/internal/admission/example_test.go b/pkg/internal/admission/example_test.go new file mode 100644 index 0000000000..3f04040717 --- /dev/null +++ b/pkg/internal/admission/example_test.go @@ -0,0 +1,43 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admission_test + +import ( + "fmt" + + "github.com/kubernetes-sigs/kubebuilder/pkg/internal/admission" + "k8s.io/api/admission/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func Example() { + resourceType := metav1.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} + admission.HandleFunc("/pod", resourceType, func(review v1beta1.AdmissionReview) *v1beta1.AdmissionResponse { + pod := corev1.Pod{} + if errResp := admission.Decode(review, &pod, resourceType); errResp != nil { + return errResp + } + // Business logic for admission decision + if len(pod.Spec.Containers) != 1 { + return admission.DenyResponse(fmt.Sprintf( + "pod %s/%s may only have 1 container.", pod.Namespace, pod.Name)) + } + return admission.AllowResponse() + }) + admission.ListenAndServeTLS("") +} diff --git a/pkg/internal/admission/handler.go b/pkg/internal/admission/handler.go new file mode 100644 index 0000000000..0623175174 --- /dev/null +++ b/pkg/internal/admission/handler.go @@ -0,0 +1,70 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admission + +import ( + "k8s.io/api/admission/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "net/http" +) + +// AdmissionFunc implements an AdmissionReview operation for a GroupVersionResource +type AdmissionFunc func(review v1beta1.AdmissionReview) *v1beta1.AdmissionResponse + +// HandleEntry +type admissionHandler struct { + GVR metav1.GroupVersionResource + Fn AdmissionFunc +} + +// handle handles an admission request and returns a result +func (ah admissionHandler) handle(review v1beta1.AdmissionReview) *v1beta1.AdmissionResponse { + return ah.handle(review) +} + +// AdmissionManager manages admission controllers +type AdmissionManager struct { + Entries map[string]admissionHandler + SMux *http.ServeMux +} + +// DefaultAdmissionFns is the default admission control functions registry +var DefaultAdmissionFns = &AdmissionManager{ + SMux: http.DefaultServeMux, +} + +// HandleFunc registers fn as an admission control webhook callback for the group,version,resources specified +func (e *AdmissionManager) HandleFunc(path string, gvr metav1.GroupVersionResource, fn AdmissionFunc) { + // Register the entry so a Webhook config is created + e.Entries[path] = admissionHandler{gvr, fn} + + // Register the handler path + e.SMux.Handle(path, httpHandler{fn}) +} + +// HandleFunc registers fn as an admission control webhook callback for the group,version,resources specified +func HandleFunc(path string, gvr metav1.GroupVersionResource, fn AdmissionFunc) { + DefaultAdmissionFns.HandleFunc(path, gvr, fn) +} + +func ListenAndServeTLS(addr string) error { + server := &http.Server{ + Addr: addr, + TLSConfig: nil, // TODO: Set this + } + return server.ListenAndServeTLS("", "") +} diff --git a/pkg/internal/admission/http.go b/pkg/internal/admission/http.go new file mode 100644 index 0000000000..41003fc441 --- /dev/null +++ b/pkg/internal/admission/http.go @@ -0,0 +1,73 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admission + +import ( + "encoding/json" + "github.com/golang/glog" + "io/ioutil" + "k8s.io/api/admission/v1beta1" + "k8s.io/apimachinery/pkg/runtime" + "net/http" +) + +func (h httpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + var body []byte + if r.Body != nil { + if data, err := ioutil.ReadAll(r.Body); err == nil { + body = data + } + } + + // verify the content type is accurate + contentType := r.Header.Get("Content-Type") + if contentType != "application/json" { + glog.Errorf("contentType=%s, expect application/json", contentType) + return + } + + var reviewResponse *v1beta1.AdmissionResponse + ar := v1beta1.AdmissionReview{} + deserializer := codecs.UniversalDeserializer() + if _, _, err := deserializer.Decode(body, nil, &ar); err != nil { + glog.Error(err) + reviewResponse = ErrorResponse(err) + } else { + reviewResponse = h.admit(ar) + } + + response := v1beta1.AdmissionReview{} + if reviewResponse != nil { + response.Response = reviewResponse + response.Response.UID = ar.Request.UID + } + // reset the Object and OldObject, they are not needed in a response. + ar.Request.Object = runtime.RawExtension{} + ar.Request.OldObject = runtime.RawExtension{} + + resp, err := json.Marshal(response) + if err != nil { + glog.Error(err) + } + if _, err := w.Write(resp); err != nil { + glog.Error(err) + } +} + +type httpHandler struct { + admit AdmissionFunc +} diff --git a/pkg/internal/admission/response.go b/pkg/internal/admission/response.go new file mode 100644 index 0000000000..24dc32d9b3 --- /dev/null +++ b/pkg/internal/admission/response.go @@ -0,0 +1,48 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admission + +import ( + "k8s.io/api/admission/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ErrorResponse creates a new AdmissionResponse for an error handling the request +func ErrorResponse(err error) *v1beta1.AdmissionResponse { + return &v1beta1.AdmissionResponse{ + Result: &metav1.Status{ + Message: err.Error(), + }, + } +} + +// DenyResponse returns a new response for denying a request +func DenyResponse(msg string) *v1beta1.AdmissionResponse { + return &v1beta1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Reason: metav1.StatusReason(msg), + }, + } +} + +// AllowResponse returns a new response for admitting a request +func AllowResponse() *v1beta1.AdmissionResponse { + return &v1beta1.AdmissionResponse{ + Allowed: true, + } +} diff --git a/pkg/internal/admission/tls.go b/pkg/internal/admission/tls.go new file mode 100644 index 0000000000..8a39651df3 --- /dev/null +++ b/pkg/internal/admission/tls.go @@ -0,0 +1,47 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admission + +import ( + "crypto/tls" + "crypto/x509" +) + +type certs struct { + Cert []byte + Key []byte + CACert []byte +} + +// MakeTLSConfig makes a TLS configuration suitable for use with the server +func makeTLSConfig(certs certs) (*tls.Config, error) { + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(certs.CACert) + //cert, err := tls.X509KeyPair(certs.Cert, certs.Key) + //if err != nil { + // return nil, err + //} + return &tls.Config{ + //Certificates: []tls.Certificate{cert}, + ClientCAs: caCertPool, + ClientAuth: tls.NoClientCert, + // Note on GKE there apparently is no client cert sent, so this + // does not work on GKE. + // TODO: make this into a configuration option. + // ClientAuth: tls.RequireAndVerifyClientCert, + }, nil +} diff --git a/pkg/log/deleg.go b/pkg/log/deleg.go new file mode 100644 index 0000000000..3a2ec03f15 --- /dev/null +++ b/pkg/log/deleg.go @@ -0,0 +1,85 @@ +package log + +import ( + "github.com/thockin/logr" +) + +// loggerPromise knows how to populate a concrete logr.Logger +// with options, given an actual base logger later on down the line. +type loggerPromise struct { + logger *DelegatingLogger + childPromises []*loggerPromise + + name *string + tags []interface{} +} + +func (p *loggerPromise) WithName(l *DelegatingLogger, name string) *loggerPromise { + res := &loggerPromise{ + logger: l, + name: &name, + } + p.childPromises = append(p.childPromises, res) + return res +} + +func (p *loggerPromise) WithTags(l *DelegatingLogger, tags ...interface{}) *loggerPromise { + res := &loggerPromise{ + logger: l, + tags: tags, + } + p.childPromises = append(p.childPromises, res) + return res +} + +func (p *loggerPromise) Fulfill(parentLogger logr.Logger) { + var logger logr.Logger = parentLogger + if p.name != nil { + logger = logger.WithName(*p.name) + } + + if p.tags != nil { + logger = logger.WithTags(p.tags...) + } + + p.logger.Logger = logger + p.logger.promise = nil + + for _, childPromise := range p.childPromises { + childPromise.Fulfill(logger) + } +} + +// delegatingLogger is a logr.Logger that delegates to another logr.Logger. +// If the underlying promise is not nil, it registers calls to sub-loggers with +// the logging factory to be populated later, and returns a new delegating +// logger. It expects to have *some* logr.Logger set at all times (generally +// a no-op logger before the promises are fulfilled). +type DelegatingLogger struct { + logr.Logger + promise *loggerPromise +} + +func (l *DelegatingLogger) WithName(name string) logr.Logger { + if l.promise == nil { + return l.Logger.WithName(name) + } + + res := &DelegatingLogger{Logger: l.Logger} + promise := l.promise.WithName(res, name) + res.promise = promise + + return res +} + +func (l *DelegatingLogger) WithTags(tags ...interface{}) logr.Logger { + if l.promise == nil { + return l.Logger.WithTags(tags) + } + + res := &DelegatingLogger{Logger: l.Logger} + promise := l.promise.WithTags(res, tags) + res.promise = promise + + return res +} diff --git a/pkg/log/log.go b/pkg/log/log.go new file mode 100644 index 0000000000..f27cfca8a1 --- /dev/null +++ b/pkg/log/log.go @@ -0,0 +1,48 @@ +// Package log contains utilities for fetching a new logger +// when one is not already available. +package log + +import ( + "log" + + "github.com/thockin/logr" + tlogr "github.com/thockin/logr/testing" + "github.com/thockin/logr/impls/zaplogr" + "go.uber.org/zap" +) + +func ZapLogger(development bool) logr.Logger { + var zapLog *zap.Logger + var err error + if development { + zapLogCfg := zap.NewDevelopmentConfig() + zapLog, err = zapLogCfg.Build(zap.AddCallerSkip(1)) + } else { + zapLogCfg := zap.NewProductionConfig() + zapLog, err = zapLogCfg.Build(zap.AddCallerSkip(1)) + } + if err != nil { + // who watches the watchmen? + log.Fatalf("unable to construct the logger: %v", err) + } + return zaplogr.NewLogger(zapLog) +} + +func SetLogger(l logr.Logger) { + Log.promise.Fulfill(l) +} + +// Log is the base logger used by kubebuilder. It delegates +// to another logr.Logger. You *must* call SetLogger to +// get any actual logging. +var Log = &DelegatingLogger{ + Logger: tlogr.NullLogger{}, + promise: &loggerPromise{}, +} + +var KBLog logr.Logger + +func init() { + Log.promise.logger = Log + KBLog = Log.WithName("kubebuilder") +} diff --git a/pkg/signals/doc.go b/pkg/signals/doc.go new file mode 100644 index 0000000000..d216eb297e --- /dev/null +++ b/pkg/signals/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// The signals package contains libraries for handling signals to shutdown the system. +package signals diff --git a/pkg/signals/dummy_imports.go b/pkg/signals/dummy_imports.go new file mode 100644 index 0000000000..508da1cfb1 --- /dev/null +++ b/pkg/signals/dummy_imports.go @@ -0,0 +1,29 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// The signals package contains libraries for handling signals to shutdown the system. +package signals + +// dummy imports to ensure that `dep ensure` can vendor the required +// dependencies after `kubebuilder init` step. +import ( + _ "github.com/emicklei/go-restful" + _ "github.com/go-openapi/spec" + _ "github.com/onsi/ginkgo" + _ "github.com/spf13/pflag" + _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" + _ "sigs.k8s.io/testing_frameworks/integration" +) diff --git a/pkg/signals/signal.go b/pkg/signals/signal.go new file mode 100644 index 0000000000..61f44717c4 --- /dev/null +++ b/pkg/signals/signal.go @@ -0,0 +1,40 @@ +/* +Copyright 2017 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package signals + +import ( + "os" + "os/signal" +) + +var onlyOneSignalHandler = make(chan struct{}) + +// SetupSignalHandler registered for SIGTERM and SIGINT. A stop channel is returned +// which is closed on one of these signals. If a second signal is caught, the program +// is terminated with exit code 1. +func SetupSignalHandler() (stopCh <-chan struct{}) { + close(onlyOneSignalHandler) // panics when called twice + + stop := make(chan struct{}) + c := make(chan os.Signal, 2) + signal.Notify(c, shutdownSignals...) + go func() { + <-c + close(stop) + <-c + os.Exit(1) // second signal. Exit directly. + }() + + return stop +} diff --git a/pkg/signals/signal_posix.go b/pkg/signals/signal_posix.go new file mode 100644 index 0000000000..81fe1739cd --- /dev/null +++ b/pkg/signals/signal_posix.go @@ -0,0 +1,23 @@ +// +build !windows + +/* +Copyright 2017 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package signals + +import ( + "os" + "syscall" +) + +var shutdownSignals = []os.Signal{os.Interrupt, syscall.SIGTERM} diff --git a/pkg/signals/signal_windows.go b/pkg/signals/signal_windows.go new file mode 100644 index 0000000000..72a5650a9f --- /dev/null +++ b/pkg/signals/signal_windows.go @@ -0,0 +1,20 @@ +/* +Copyright 2017 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package signals + +import ( + "os" +) + +var shutdownSignals = []os.Signal{os.Interrupt} diff --git a/pkg/test/doc.go b/pkg/test/doc.go new file mode 100644 index 0000000000..f4df1924c6 --- /dev/null +++ b/pkg/test/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// The test package provides libaries for integration testing by starting a local control plane +package test diff --git a/pkg/test/ginkgo.go b/pkg/test/ginkgo.go new file mode 100644 index 0000000000..cb1e8db0cd --- /dev/null +++ b/pkg/test/ginkgo.go @@ -0,0 +1,42 @@ +/* +Copyright 2016 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package test + +import ( + "fmt" + + . "github.com/onsi/ginkgo/config" + . "github.com/onsi/ginkgo/types" +) + +// Print a newline after the default Reporter output so that the results are correctly parsed +// by test automation. +// See issue https://github.com/jstemmer/go-junit-report/issues/31 +type NewlineReporter struct{} + +func (NewlineReporter) SpecSuiteWillBegin(config GinkgoConfigType, summary *SuiteSummary) {} + +func (NewlineReporter) BeforeSuiteDidRun(setupSummary *SetupSummary) {} + +func (NewlineReporter) AfterSuiteDidRun(setupSummary *SetupSummary) {} + +func (NewlineReporter) SpecWillRun(specSummary *SpecSummary) {} + +func (NewlineReporter) SpecDidComplete(specSummary *SpecSummary) {} + +// SpecSuiteDidEnd Prints a newline between "35 Passed | 0 Failed | 0 Pending | 0 Skipped" and "--- PASS:" +func (NewlineReporter) SpecSuiteDidEnd(summary *SuiteSummary) { fmt.Printf("\n") } diff --git a/pkg/test/server.go b/pkg/test/server.go new file mode 100644 index 0000000000..bab6770d60 --- /dev/null +++ b/pkg/test/server.go @@ -0,0 +1,95 @@ +/* +Copyright 2016 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package test + +import ( + "os" + "time" + + "github.com/kubernetes-sigs/kubebuilder/pkg/install" + extensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + "k8s.io/client-go/rest" + "sigs.k8s.io/testing_frameworks/integration" +) + +// Default binary path for test framework +const ( + envKubeAPIServerBin = "TEST_ASSET_KUBE_APISERVER" + envEtcdBin = "TEST_ASSET_ETCD" + defaultKubeAPIServerBin = "/usr/local/kubebuilder/bin/kube-apiserver" + defaultEtcdBin = "/usr/local/kubebuilder/bin/etcd" +) + +// TestEnvironment creates a Kubernetes test environment that will start / stop the Kubernetes control plane and +// install extension APIs +type TestEnvironment struct { + ControlPlane integration.ControlPlane + Config *rest.Config + CRDs []*extensionsv1beta1.CustomResourceDefinition +} + +// Stop stops a running server +func (te *TestEnvironment) Stop() { + te.ControlPlane.Stop() +} + +// Start starts a local Kubernetes server and updates te.ApiserverPort with the port it is listening on +func (te *TestEnvironment) Start() (*rest.Config, error) { + te.ControlPlane = integration.ControlPlane{} + if os.Getenv(envKubeAPIServerBin) == "" { + te.ControlPlane.APIServer = &integration.APIServer{Path: defaultKubeAPIServerBin} + } + if os.Getenv(envEtcdBin) == "" { + te.ControlPlane.Etcd = &integration.Etcd{Path: defaultEtcdBin} + } + + // Start the control plane - retry if it fails + var err error + for i := 0; i < 5; i++ { + err = te.ControlPlane.Start() + if err == nil { + break + } + } + // Give up trying to start the control plane + if err != nil { + return nil, err + } + + // Create the *rest.Config for creating new clients + te.Config = &rest.Config{ + Host: te.ControlPlane.APIURL().Host, + } + + // Add CRDs to the apiserver + err = install.NewInstaller(te.Config).Install(&InstallStrategy{crds: te.CRDs}) + + // Wait for discovery service to register CRDs + // TODO: Poll for this or find a better way of ensuring CRDs are registered in discovery + time.Sleep(time.Second * 1) + + return te.Config, err +} + +type InstallStrategy struct { + install.EmptyInstallStrategy + crds []*extensionsv1beta1.CustomResourceDefinition +} + +func (s *InstallStrategy) GetCRDs() []*extensionsv1beta1.CustomResourceDefinition { + return s.crds +}