working commit

This commit is contained in:
2026-03-13 19:02:42 +02:00
parent bebbf79c7a
commit 5c1da77f4c
1329 changed files with 314708 additions and 39 deletions
@@ -0,0 +1,47 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package aggregator
import (
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
)
// AggregateStatus computes the aggregate status for all the resources.
// The rules are the following:
// - If any of the resources has the FailedStatus, the aggregate status is also
// FailedStatus
// - If none of the resources have the FailedStatus and at least one is
// UnknownStatus, the aggregate status is UnknownStatus
// - If all the resources have the desired status, the aggregate status is the
// desired status.
// - If none of the first three rules apply, the aggregate status is
// InProgressStatus
func AggregateStatus(rss []*event.ResourceStatus, desired status.Status) status.Status {
if len(rss) == 0 {
return desired
}
allDesired := true
anyUnknown := false
for _, rs := range rss {
s := rs.Status
if s == status.FailedStatus {
return status.FailedStatus
}
if s == status.UnknownStatus {
anyUnknown = true
}
if s != desired {
allDesired = false
}
}
if anyUnknown {
return status.UnknownStatus
}
if allDesired {
return desired
}
return status.InProgressStatus
}
@@ -0,0 +1,338 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package clusterreader
import (
"context"
"errors"
"fmt"
"sync"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
"github.com/fluxcd/cli-utils/pkg/object"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/tools/pager"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// This map is hard-coded knowledge that a Deployment contains and
// ReplicaSet, and that a ReplicaSet in turn contains Pods, etc., and the
// approach to finding status being used here requires hardcoding that
// knowledge in the status client library.
// TODO: These should probably be defined in the statusreaders rather than here.
var genGroupKinds = map[schema.GroupKind][]schema.GroupKind{
schema.GroupKind{Group: "apps", Kind: "Deployment"}: { //nolint:gofmt
{
Group: "apps",
Kind: "ReplicaSet",
},
},
schema.GroupKind{Group: "apps", Kind: "ReplicaSet"}: { //nolint:gofmt
{
Group: "",
Kind: "Pod",
},
},
schema.GroupKind{Group: "apps", Kind: "StatefulSet"}: { //nolint:gofmt
{
Group: "",
Kind: "Pod",
},
},
}
// NewCachingClusterReader returns a new instance of the ClusterReader. The
// ClusterReader needs will use the clusterreader to fetch resources from the cluster,
// while the mapper is used to resolve the version for GroupKinds. The set of
// identifiers is needed so the ClusterReader can figure out which GroupKind
// and namespace combinations it needs to cache when the Sync function is called.
// We only want to fetch the resources that are actually needed.
func NewCachingClusterReader(reader client.Reader, mapper meta.RESTMapper, identifiers object.ObjMetadataSet) (engine.ClusterReader, error) {
gvkNamespaceSet := newGnSet()
for _, id := range identifiers {
// For every identifier, add the GroupVersionKind and namespace combination to the gvkNamespaceSet and
// check the genGroupKinds map for any generated resources that also should be included.
err := buildGvkNamespaceSet([]schema.GroupKind{id.GroupKind}, id.Namespace, gvkNamespaceSet)
if err != nil {
return nil, err
}
}
return &CachingClusterReader{
reader: reader,
mapper: mapper,
gns: gvkNamespaceSet.gvkNamespaces,
}, nil
}
func buildGvkNamespaceSet(gks []schema.GroupKind, namespace string, gvkNamespaceSet *gvkNamespaceSet) error {
for _, gk := range gks {
gvkNamespaceSet.add(gkNamespace{
GroupKind: gk,
Namespace: namespace,
})
genGKs, found := genGroupKinds[gk]
if found {
err := buildGvkNamespaceSet(genGKs, namespace, gvkNamespaceSet)
if err != nil {
return err
}
}
}
return nil
}
type gvkNamespaceSet struct {
gvkNamespaces []gkNamespace
seen map[gkNamespace]struct{}
}
func newGnSet() *gvkNamespaceSet {
return &gvkNamespaceSet{
seen: make(map[gkNamespace]struct{}),
}
}
func (g *gvkNamespaceSet) add(gn gkNamespace) {
if _, found := g.seen[gn]; !found {
g.gvkNamespaces = append(g.gvkNamespaces, gn)
g.seen[gn] = struct{}{}
}
}
// CachingClusterReader is an implementation of the ObserverReader interface that will
// pre-fetch all resources needed before every sync loop. The resources needed are decided by
// finding all combinations of GroupVersionKind and namespace referenced by the provided
// identifiers. This list is then expanded to include any known generated resource types.
type CachingClusterReader struct {
mx sync.RWMutex
// clusterreader provides functions to read and list resources from the
// cluster.
reader client.Reader
// mapper is the client-side representation of the server-side scheme. It is used
// to resolve GroupVersionKind from GroupKind.
mapper meta.RESTMapper
// gns contains the slice of all the GVK and namespace combinations that
// should be included in the cache. This is computed based the resource identifiers
// passed in when the CachingClusterReader is created and augmented with other
// resource types needed to compute status (see genGroupKinds).
gns []gkNamespace
// cache contains the resources found in the cluster for the given combination
// of GVK and namespace. Before each polling cycle, the framework will call the
// Sync function, which is responsible for repopulating the cache.
cache map[gkNamespace]cacheEntry
}
type cacheEntry struct {
resources unstructured.UnstructuredList
err error
}
// gkNamespace contains information about a GroupVersionKind and a namespace.
type gkNamespace struct {
GroupKind schema.GroupKind
Namespace string
}
// Get looks up the resource identified by the key and the object GVK in the cache. If the needed combination
// of GVK and namespace is not part of the cache, that is considered an error.
func (c *CachingClusterReader) Get(_ context.Context, key client.ObjectKey, obj *unstructured.Unstructured) error {
c.mx.RLock()
defer c.mx.RUnlock()
gvk := obj.GetObjectKind().GroupVersionKind()
mapping, err := c.mapper.RESTMapping(gvk.GroupKind())
if err != nil {
return err
}
gn := gkNamespace{
GroupKind: gvk.GroupKind(),
Namespace: key.Namespace,
}
cacheEntry, found := c.cache[gn]
if !found {
return fmt.Errorf("GVK %s and Namespace %s not found in cache", gvk.String(), gn.Namespace)
}
if cacheEntry.err != nil {
return cacheEntry.err
}
for _, u := range cacheEntry.resources.Items {
if u.GetName() == key.Name {
obj.Object = u.Object
return nil
}
}
return apierrors.NewNotFound(mapping.Resource.GroupResource(), key.Name)
}
// ListNamespaceScoped lists all resource identifier by the GVK of the list, the namespace and the selector
// from the cache. If the needed combination of GVK and namespace is not part of the cache, that is considered an error.
func (c *CachingClusterReader) ListNamespaceScoped(_ context.Context, list *unstructured.UnstructuredList, namespace string, selector labels.Selector) error {
c.mx.RLock()
defer c.mx.RUnlock()
gvk := list.GroupVersionKind()
gn := gkNamespace{
GroupKind: gvk.GroupKind(),
Namespace: namespace,
}
cacheEntry, found := c.cache[gn]
if !found {
return fmt.Errorf("GVK %s and Namespace %s not found in cache", gvk.String(), gn.Namespace)
}
if cacheEntry.err != nil {
return cacheEntry.err
}
var items []unstructured.Unstructured
for _, u := range cacheEntry.resources.Items {
if selector.Matches(labels.Set(u.GetLabels())) {
items = append(items, u)
}
}
list.Items = items
return nil
}
// ListClusterScoped lists all resource identifier by the GVK of the list and selector
// from the cache. If the needed combination of GVK and namespace (which for clusterscoped resources
// will always be the empty string) is not part of the cache, that is considered an error.
func (c *CachingClusterReader) ListClusterScoped(ctx context.Context, list *unstructured.UnstructuredList, selector labels.Selector) error {
return c.ListNamespaceScoped(ctx, list, "", selector)
}
// Sync loops over the list of gkNamespace we know of, and uses list calls to fetch the resources.
// This information populates the cache.
func (c *CachingClusterReader) Sync(ctx context.Context) error {
c.mx.Lock()
defer c.mx.Unlock()
cache := make(map[gkNamespace]cacheEntry)
for _, gn := range c.gns {
mapping, err := c.mapper.RESTMapping(gn.GroupKind)
if err != nil {
if meta.IsNoMatchError(err) {
// If we get a NoMatchError, it means we are checking for
// a type that doesn't exist. Presumably the CRD is being
// applied, so it will be added. Reset the RESTMapper to
// make sure we pick up any new resource types on the
// APIServer.
cache[gn] = cacheEntry{
err: err,
}
continue
}
return err
}
ns := ""
if mapping.Scope == meta.RESTScopeNamespace {
ns = gn.Namespace
}
list, err := c.listUnstructured(ctx, mapping.GroupVersionKind, ns)
if err != nil {
// If the context was cancelled, we just stop the work and return
// the error.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
// For other errors, we just keep it the error. Whenever any pollers
// request a resource covered by this gns, we just return the
// error.
cache[gn] = cacheEntry{
err: err,
}
continue
}
cache[gn] = cacheEntry{
resources: *list,
}
}
c.cache = cache
return nil
}
// listUnstructured performs one or more LIST calls, paginating the requests
// and aggregating the results. If aggregated, only the ResourceVersion,
// SelfLink, and Items will be populated. The default page size is 500.
func (c *CachingClusterReader) listUnstructured(
ctx context.Context,
gvk schema.GroupVersionKind,
namespace string,
) (*unstructured.UnstructuredList, error) {
mOpts := metav1.ListOptions{}
mOpts.SetGroupVersionKind(gvk)
obj, _, err := pager.New(c.listPageFunc(namespace)).List(ctx, mOpts)
if err != nil {
return nil, err
}
switch t := obj.(type) {
case *unstructured.UnstructuredList:
// all in one
return t, nil
case *metainternalversion.List:
// aggregated result
u := &unstructured.UnstructuredList{}
u.SetGroupVersionKind(gvk)
// Only ResourceVersion & SelfLink are copied into the aggregated result
// by ListPager.
if t.ResourceVersion != "" {
u.SetResourceVersion(t.ResourceVersion)
}
if t.SelfLink != "" { // nolint:staticcheck
u.SetSelfLink(t.SelfLink) // nolint:staticcheck
}
u.Items = make([]unstructured.Unstructured, len(t.Items))
for i, item := range t.Items {
ui, ok := item.(*unstructured.Unstructured)
if !ok {
return nil, fmt.Errorf("unexpected list item type: %t", item)
}
u.Items[i] = *ui
}
return u, nil
default:
return nil, fmt.Errorf("unexpected list type: %t", t)
}
}
func (c *CachingClusterReader) listPageFunc(namespace string) pager.ListPageFunc {
return func(ctx context.Context, mOpts metav1.ListOptions) (runtime.Object, error) {
mOptsCopy := mOpts
labelSelector, err := labels.Parse(mOpts.LabelSelector)
if err != nil {
return nil, fmt.Errorf("failed to parse label selector: %w", err)
}
fieldSelector, err := fields.ParseSelector(mOpts.FieldSelector)
if err != nil {
return nil, fmt.Errorf("failed to parse field selector: %w", err)
}
cOpts := &client.ListOptions{
LabelSelector: labelSelector,
FieldSelector: fieldSelector,
Namespace: namespace,
Limit: mOpts.Limit,
Continue: mOpts.Continue,
Raw: &mOptsCopy,
}
var list unstructured.UnstructuredList
list.SetGroupVersionKind(mOpts.GroupVersionKind())
// Note: client.ListOptions only supports Exact ResourceVersion matching.
// So leave ResourceVersion blank to get Any ResourceVersion.
err = c.reader.List(ctx, &list, cOpts)
return &list, err
}
}
@@ -0,0 +1,45 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package clusterreader
import (
"context"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
"github.com/fluxcd/cli-utils/pkg/object"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/labels"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// NewDirectClusterReader creates a new implementation of the engine.ClusterReader interface that makes
// calls directly to the cluster without any caching.
func NewDirectClusterReader(reader client.Reader, _ meta.RESTMapper, _ object.ObjMetadataSet) (engine.ClusterReader, error) {
return &DirectClusterReader{
Reader: reader,
}, nil
}
// DirectClusterReader is an implementation of the ClusterReader that just delegates all calls directly to
// the underlying clusterreader. No caching.
type DirectClusterReader struct {
Reader client.Reader
}
func (n *DirectClusterReader) Get(ctx context.Context, key client.ObjectKey, obj *unstructured.Unstructured) error {
return n.Reader.Get(ctx, key, obj)
}
func (n *DirectClusterReader) ListNamespaceScoped(ctx context.Context, list *unstructured.UnstructuredList, namespace string, selector labels.Selector) error {
return n.Reader.List(ctx, list, client.InNamespace(namespace), client.MatchingLabelsSelector{Selector: selector})
}
func (n *DirectClusterReader) ListClusterScoped(ctx context.Context, list *unstructured.UnstructuredList, selector labels.Selector) error {
return n.Reader.List(ctx, list, client.MatchingLabelsSelector{Selector: selector})
}
func (n *DirectClusterReader) Sync(_ context.Context) error {
return nil
}
@@ -0,0 +1,81 @@
// Copyright 2022 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package clusterreader
import (
"context"
"fmt"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/client-go/dynamic"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// DynamicClusterReader is an implementation of the ClusterReader that delegates
// all calls directly to the underlying DynamicClient. No caching.
type DynamicClusterReader struct {
DynamicClient dynamic.Interface
Mapper meta.RESTMapper
}
func (n *DynamicClusterReader) Get(ctx context.Context, key client.ObjectKey, obj *unstructured.Unstructured) error {
mapping, err := n.Mapper.RESTMapping(obj.GroupVersionKind().GroupKind())
if err != nil {
return fmt.Errorf("failed to map object: %w", err)
}
serverObj, err := n.DynamicClient.Resource(mapping.Resource).
Namespace(key.Namespace).
Get(ctx, key.Name, metav1.GetOptions{})
if err != nil {
return err
}
serverObj.DeepCopyInto(obj)
return nil
}
func (n *DynamicClusterReader) ListNamespaceScoped(ctx context.Context, list *unstructured.UnstructuredList, namespace string, selector labels.Selector) error {
mapping, err := n.Mapper.RESTMapping(list.GroupVersionKind().GroupKind())
if err != nil {
return fmt.Errorf("failed to map object: %w", err)
}
serverObj, err := n.DynamicClient.Resource(mapping.Resource).
Namespace(namespace).
List(ctx, metav1.ListOptions{
LabelSelector: selector.String(),
})
if err != nil {
return err
}
serverObj.DeepCopyInto(list)
return nil
}
func (n *DynamicClusterReader) ListClusterScoped(ctx context.Context, list *unstructured.UnstructuredList, selector labels.Selector) error {
mapping, err := n.Mapper.RESTMapping(list.GroupVersionKind().GroupKind())
if err != nil {
return fmt.Errorf("failed to map object: %w", err)
}
serverObj, err := n.DynamicClient.Resource(mapping.Resource).
List(ctx, metav1.ListOptions{
LabelSelector: selector.String(),
})
if err != nil {
return err
}
serverObj.DeepCopyInto(list)
return nil
}
func (n *DynamicClusterReader) Sync(_ context.Context) error {
return nil
}
@@ -0,0 +1,141 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package collector
import (
"sort"
"sync"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
"github.com/fluxcd/cli-utils/pkg/object"
)
func NewResourceStatusCollector(identifiers object.ObjMetadataSet) *ResourceStatusCollector {
resourceStatuses := make(map[object.ObjMetadata]*event.ResourceStatus)
for _, id := range identifiers {
resourceStatuses[id] = &event.ResourceStatus{
Identifier: id,
Status: status.UnknownStatus,
}
}
return &ResourceStatusCollector{
ResourceStatuses: resourceStatuses,
}
}
// Observer is an interface that can be implemented to have the
// ResourceStatusCollector invoke the function on every event that
// comes through the eventChannel.
// The callback happens in the processing goroutine and while the
// goroutine holds the lock, so any processing in the callback
// must be done quickly.
type Observer interface {
Notify(*ResourceStatusCollector, event.Event)
}
// ObserverFunc is a function implementation of the Observer
// interface.
type ObserverFunc func(*ResourceStatusCollector, event.Event)
func (o ObserverFunc) Notify(rsc *ResourceStatusCollector, e event.Event) {
o(rsc, e)
}
// ResourceStatusCollector is for use by clients of the polling library and provides
// a way to keep track of the latest status/state for all the polled resources. The collector
// is set up to listen to the eventChannel and keep the latest event for each resource. It also
// provides a way to fetch the latest state for all resources and the aggregated status at any point.
// The functions already handles synchronization so it can be used by multiple goroutines.
type ResourceStatusCollector struct {
mux sync.RWMutex
LastEventType event.Type
ResourceStatuses map[object.ObjMetadata]*event.ResourceStatus
Error error
}
// ListenerResult is the type of the object passed back to the caller to
// Listen and ListenWithObserver if a fatal error has been encountered.
type ListenerResult struct {
Err error
}
// Listen kicks off the goroutine that will listen for the events on the
// eventChannel. It returns a channel that will be closed the collector stops
// listening to the eventChannel.
func (o *ResourceStatusCollector) Listen(eventChannel <-chan event.Event) <-chan ListenerResult {
return o.ListenWithObserver(eventChannel, nil)
}
// ListenWithObserver kicks off the goroutine that will listen for the events on the
// eventChannel. It returns a channel that will be closed the collector stops
// listening to the eventChannel.
// The provided observer will be invoked on every event, after the event
// has been processed.
func (o *ResourceStatusCollector) ListenWithObserver(eventChannel <-chan event.Event,
observer Observer) <-chan ListenerResult {
completed := make(chan ListenerResult)
go func() {
defer close(completed)
for e := range eventChannel {
err := o.processEvent(e)
if err != nil {
completed <- ListenerResult{
Err: err,
}
}
if observer != nil {
observer.Notify(o, e)
}
}
}()
return completed
}
func (o *ResourceStatusCollector) processEvent(e event.Event) error {
o.mux.Lock()
defer o.mux.Unlock()
o.LastEventType = e.Type
if e.Type == event.ErrorEvent {
o.Error = e.Error
return e.Error
}
if e.Type == event.ResourceUpdateEvent {
resourceStatus := e.Resource
o.ResourceStatuses[resourceStatus.Identifier] = resourceStatus
}
return nil
}
// Observation contains the latest state known by the collector as returned
// by a call to the LatestObservation function.
type Observation struct {
LastEventType event.Type
ResourceStatuses []*event.ResourceStatus
Error error
}
// LatestObservation returns an Observation instance, which contains the
// latest information about the resources known by the collector.
func (o *ResourceStatusCollector) LatestObservation() *Observation {
o.mux.RLock()
defer o.mux.RUnlock()
var resourceStatuses event.ResourceStatuses
for _, resourceStatus := range o.ResourceStatuses {
resourceStatuses = append(resourceStatuses, resourceStatus)
}
sort.Sort(resourceStatuses)
return &Observation{
LastEventType: o.LastEventType,
ResourceStatuses: resourceStatuses,
Error: o.Error,
}
}
+257
View File
@@ -0,0 +1,257 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package engine
import (
"context"
"errors"
"fmt"
"time"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
"github.com/fluxcd/cli-utils/pkg/object"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// ClusterReaderFactory provides an interface that can be implemented to provide custom
// ClusterReader implementations in the StatusPoller.
type ClusterReaderFactory interface {
New(reader client.Reader, mapper meta.RESTMapper, identifiers object.ObjMetadataSet) (ClusterReader, error)
}
type ClusterReaderFactoryFunc func(client.Reader, meta.RESTMapper, object.ObjMetadataSet) (ClusterReader, error)
func (c ClusterReaderFactoryFunc) New(r client.Reader, m meta.RESTMapper, ids object.ObjMetadataSet) (ClusterReader, error) {
return c(r, m, ids)
}
// PollerEngine provides functionality for polling a cluster for status of a set of resources.
type PollerEngine struct {
Reader client.Reader
Mapper meta.RESTMapper
StatusReaders []StatusReader
DefaultStatusReader StatusReader
ClusterReaderFactory ClusterReaderFactory
}
// Poll will create a new statusPollerRunner that will poll all the resources provided and report their status
// back on the event channel returned. The statusPollerRunner can be cancelled at any time by cancelling the
// context passed in.
// The context can be used to stop the polling process by using timeout, deadline or
// cancellation.
func (s *PollerEngine) Poll(ctx context.Context, identifiers object.ObjMetadataSet, options Options) <-chan event.Event {
eventChannel := make(chan event.Event)
go func() {
defer close(eventChannel)
err := s.validateIdentifiers(identifiers)
if err != nil {
handleError(eventChannel, err)
return
}
clusterReader, err := s.ClusterReaderFactory.New(s.Reader, s.Mapper, identifiers)
if err != nil {
handleError(eventChannel, fmt.Errorf("error creating new ClusterReader: %w", err))
return
}
runner := &statusPollerRunner{
clusterReader: clusterReader,
statusReaders: s.StatusReaders,
defaultStatusReader: s.DefaultStatusReader,
identifiers: identifiers,
previousResourceStatuses: make(map[object.ObjMetadata]*event.ResourceStatus),
eventChannel: eventChannel,
pollingInterval: options.PollInterval,
}
runner.Run(ctx)
}()
return eventChannel
}
func handleError(eventChannel chan event.Event, err error) {
eventChannel <- event.Event{
Type: event.ErrorEvent,
Error: err,
}
}
// validateIdentifiers makes sure that all namespaced resources
// passed in
func (s *PollerEngine) validateIdentifiers(identifiers object.ObjMetadataSet) error {
for _, id := range identifiers {
mapping, err := s.Mapper.RESTMapping(id.GroupKind)
if err != nil {
// If we can't find a match, just keep going. This can happen
// if CRDs and CRs are applied at the same time.
if meta.IsNoMatchError(err) {
continue
}
return err
}
if mapping.Scope.Name() == meta.RESTScopeNameNamespace && id.Namespace == "" {
return fmt.Errorf("resource %s %s is namespace scoped, but namespace is not set",
id.GroupKind.String(), id.Name)
}
}
return nil
}
// Options contains the different parameters that can be used to adjust the
// behavior of the PollerEngine.
// Timeout is not one of the options here as this should be accomplished by
// setting a timeout on the context: https://golang.org/pkg/context/
type Options struct {
// PollInterval defines how often the PollerEngine should poll the cluster for the latest
// state of the resources.
PollInterval time.Duration
}
// statusPollerRunner is responsible for polling of a set of resources. Each call to Poll will create
// a new statusPollerRunner, which means we can keep state in the runner and all data will only be accessed
// by a single goroutine, meaning we don't need synchronization.
// The statusPollerRunner uses an implementation of the ClusterReader interface to talk to the
// kubernetes cluster. Currently this can be either the cached ClusterReader that syncs all needed resources
// with LIST calls before each polling loop, or the normal ClusterReader that just forwards each call
// to the client.Reader from controller-runtime.
type statusPollerRunner struct {
// clusterReader is the interface for fetching and listing resources from the cluster. It can be implemented
// to make call directly to the cluster or use caching to reduce the number of calls to the cluster.
clusterReader ClusterReader
// statusReaders contains the resource specific statusReaders. These will contain logic for how to
// compute status for specific GroupKinds. These will use an ClusterReader to fetch
// status of a resource and any generated resources.
statusReaders []StatusReader
// defaultStatusReader is the generic engine that is used for all GroupKinds that
// doesn't have a specific engine in the statusReaders map.
defaultStatusReader StatusReader
// identifiers contains the set of identifiers for the resources that should be polled.
// Each resource is identified by GroupKind, namespace and name.
identifiers object.ObjMetadataSet
// previousResourceStatuses keeps track of the last event for each
// of the polled resources. This is used to make sure we only
// send events on the event channel when something has actually changed.
previousResourceStatuses map[object.ObjMetadata]*event.ResourceStatus
// eventChannel is a channel where any updates to the status of resources
// will be sent. The caller of Poll will listen for updates.
eventChannel chan event.Event
// pollingInterval determines how often we should poll the cluster for
// the latest state of resources.
pollingInterval time.Duration
}
// Run starts the polling loop of the statusReaders.
func (r *statusPollerRunner) Run(ctx context.Context) {
// Sets up ticker that will trigger the regular polling loop at a regular interval.
ticker := time.NewTicker(r.pollingInterval)
defer func() {
ticker.Stop()
}()
err := r.syncAndPoll(ctx)
if err != nil {
r.handleSyncAndPollErr(err)
return
}
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
// First sync and then compute status for all resources.
err := r.syncAndPoll(ctx)
if err != nil {
r.handleSyncAndPollErr(err)
return
}
}
}
}
// handleSyncAndPollErr decides what to do if we encounter an error while
// fetching resources to compute status. Errors are usually returned
// as an ErrorEvent, but we handle context cancellation or deadline exceeded
// differently since they aren't really errors, but a signal that the
// process should shut down.
func (r *statusPollerRunner) handleSyncAndPollErr(err error) {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return
}
r.eventChannel <- event.Event{
Type: event.ErrorEvent,
Error: err,
}
}
func (r *statusPollerRunner) syncAndPoll(ctx context.Context) error {
// First trigger a sync of the ClusterReader. This may or may not actually
// result in calls to the cluster, depending on the implementation.
// If this call fails, there is no clean way to recover, so we just return an ErrorEvent
// and shut down.
err := r.clusterReader.Sync(ctx)
if err != nil {
return err
}
// Poll all resources and compute status. If the polling of resources has completed (based
// on information from the StatusAggregator and the value of pollUntilCancelled), we send
// a CompletedEvent and return.
return r.pollStatusForAllResources(ctx)
}
// pollStatusForAllResources iterates over all the resources in the set and delegates
// to the appropriate engine to compute the status.
func (r *statusPollerRunner) pollStatusForAllResources(ctx context.Context) error {
for _, id := range r.identifiers {
// Check if the context has been cancelled on every iteration.
select {
case <-ctx.Done():
return ctx.Err()
default:
}
gk := id.GroupKind
statusReader := r.statusReaderForGroupKind(gk)
resourceStatus, err := statusReader.ReadStatus(ctx, r.clusterReader, id)
if err != nil {
return err
}
if r.isUpdatedResourceStatus(resourceStatus) {
r.previousResourceStatuses[id] = resourceStatus
r.eventChannel <- event.Event{
Type: event.ResourceUpdateEvent,
Resource: resourceStatus,
}
}
}
return nil
}
func (r *statusPollerRunner) statusReaderForGroupKind(gk schema.GroupKind) StatusReader {
for _, sr := range r.statusReaders {
if sr.Supports(gk) {
return sr
}
}
return r.defaultStatusReader
}
func (r *statusPollerRunner) isUpdatedResourceStatus(resourceStatus *event.ResourceStatus) bool {
oldResourceStatus, found := r.previousResourceStatuses[resourceStatus.Identifier]
if !found {
return true
}
return !event.ResourceStatusEqual(resourceStatus, oldResourceStatus)
}
@@ -0,0 +1,32 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package engine
import (
"context"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/labels"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// ClusterReader is the interface provided to the statusReaders to talk to the cluster. Implementations
// of this interface allows different caching strategies, for example by pre-fetching resources using
// LIST calls rather than letting each engine run multiple GET calls against the cluster. This can
// significantly reduce the number of requests.
type ClusterReader interface {
// Get looks up the resource identifier by the key and the GVK in the provided obj reference. If something
// goes wrong or the resource doesn't exist, an error is returned.
Get(ctx context.Context, key client.ObjectKey, obj *unstructured.Unstructured) error
// ListNamespaceScoped looks up the resources of the GVK given in the list and matches the namespace and
// selector provided.
ListNamespaceScoped(ctx context.Context, list *unstructured.UnstructuredList,
namespace string, selector labels.Selector) error
// ListClusterScoped looks up the resources of the GVK given in the list and that matches the selector
// provided.
ListClusterScoped(ctx context.Context, list *unstructured.UnstructuredList, selector labels.Selector) error
// Sync is called by the engine before every polling loop, which provides an opportunity for the Reader
// to sync caches.
Sync(ctx context.Context) error
}
@@ -0,0 +1,43 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package engine
import (
"context"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
"github.com/fluxcd/cli-utils/pkg/object"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
)
// StatusReader is the main interface for computing status for resources. In this context,
// a status reader is an object that can fetch a resource of a specific
// GroupKind from the cluster and compute its status. For resources that
// can own generated resources, the engine might also have knowledge about
// how to identify these generated resources and how to compute status for
// these generated resources.
type StatusReader interface {
// Supports tells the caller whether the StatusReader can compute status for
// the provided GroupKind.
Supports(schema.GroupKind) bool
// ReadStatus will fetch the resource identified by the given identifier
// from the cluster and return an ResourceStatus that will contain
// information about the latest state of the resource, its computed status
// and information about any generated resources. Errors would usually be
// added to the event.ResourceStatus, but in the case of fatal errors
// that aren't connected to the particular resource, an error can also
// be returned. Currently, only context cancellation and deadline exceeded
// will cause an error to be returned.
ReadStatus(ctx context.Context, reader ClusterReader, resource object.ObjMetadata) (*event.ResourceStatus, error)
// ReadStatusForObject is similar to ReadStatus, but instead of looking up the
// resource based on an identifier, it will use the passed-in resource.
// Errors would usually be added to the event.ResourceStatus, but in the case
// of fatal errors that aren't connected to the particular resource, an error
// can also be returned. Currently, only context cancellation and deadline exceeded
// will cause an error to be returned.
ReadStatusForObject(ctx context.Context, reader ClusterReader, object *unstructured.Unstructured) (*event.ResourceStatus, error)
}
+165
View File
@@ -0,0 +1,165 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package event
import (
"fmt"
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
"github.com/fluxcd/cli-utils/pkg/object"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// Type is the type that describes the type of an Event that is passed back to the caller
// as resources in the cluster are being polled.
//
//go:generate stringer -type=Type -linecomment
type Type int
const (
// ResourceUpdateEvent describes events related to a change in the status of one of the polled resources.
ResourceUpdateEvent Type = iota // Update
// ErrorEvent signals that the engine has encountered an error that it can not recover from. The engine
// is shutting down and the event channel will be closed after this event.
ErrorEvent // Error
// SyncEvent signals that the engine has completed its initial
// synchronization, and the cache is primed. After this point, it's safe to
// assume that you won't miss events caused by your own subsequent actions.
SyncEvent // Sync
)
// Event defines that type that is passed back through the event channel to notify the caller of changes
// as resources are being polled.
type Event struct {
// Type defines the type of event.
Type Type
// Resource is only available for ResourceUpdateEvents. It includes information about the resource,
// including the resource status, any errors and the resource itself (as an unstructured).
Resource *ResourceStatus
// Error is only available for ErrorEvents. It contains the error that caused the engine to
// give up.
Error error
}
// String returns a string suitable for logging
func (e Event) String() string {
if e.Error != nil {
return fmt.Sprintf("Event{ Type: %q, Resource: %v, Error: %q }",
e.Type, e.Resource, e.Error)
}
return fmt.Sprintf("Event{ Type: %q, Resource: %v }",
e.Type, e.Resource)
}
// ResourceStatus contains information about a resource after we have
// fetched it from the cluster and computed status.
type ResourceStatus struct {
// Identifier contains the information necessary to locate the
// resource within a cluster.
Identifier object.ObjMetadata
// Status is the computed status for this resource.
Status status.Status
// Resource contains the actual manifest for the resource that
// was fetched from the cluster and used to compute status.
Resource *unstructured.Unstructured
// Errors contains the error if something went wrong during the
// process of fetching the resource and computing the status.
Error error
// Message is text describing the status of the resource.
Message string
// GeneratedResources is a slice of ResourceStatus that
// contains information and status for any generated resources
// of the current resource.
GeneratedResources ResourceStatuses
}
// String returns a string suitable for logging
func (rs ResourceStatus) String() string {
if rs.Error != nil {
return fmt.Sprintf("ResourceStatus{ Identifier: %q, Status: %q, Message: %q, Resource: %v, GeneratedResources: %v, Error: %q }",
rs.Identifier, rs.Status, rs.Message, rs.Resource, rs.GeneratedResources, rs.Error)
}
return fmt.Sprintf("ResourceStatus{ Identifier: %q, Status: %q, Message: %q, Resource: %v, GeneratedResources: %v }",
rs.Identifier, rs.Status, rs.Message, rs.Resource, rs.GeneratedResources)
}
type ResourceStatuses []*ResourceStatus
func (g ResourceStatuses) Len() int {
return len(g)
}
func (g ResourceStatuses) Less(i, j int) bool {
idI := g[i].Identifier
idJ := g[j].Identifier
if idI.Namespace != idJ.Namespace {
return idI.Namespace < idJ.Namespace
}
if idI.GroupKind.Group != idJ.GroupKind.Group {
return idI.GroupKind.Group < idJ.GroupKind.Group
}
if idI.GroupKind.Kind != idJ.GroupKind.Kind {
return idI.GroupKind.Kind < idJ.GroupKind.Kind
}
return idI.Name < idJ.Name
}
func (g ResourceStatuses) Swap(i, j int) {
g[i], g[j] = g[j], g[i]
}
// ResourceStatusEqual checks if two instances of ResourceStatus are the same.
// This is used to determine whether status has changed for a particular resource.
// Important to note that this does not check all fields, but only the ones
// that are considered part of the status for a resource. So if the status
// or the message of an ResourceStatus (or any of its generated ResourceStatuses)
// have changed, this will return true. Changes to the state of the resource
// itself that doesn't impact status are not considered.
func ResourceStatusEqual(or1, or2 *ResourceStatus) bool {
if or1.Identifier != or2.Identifier ||
or1.Status != or2.Status ||
or1.Message != or2.Message {
return false
}
// Check if generation has changed to make sure that even if
// an update to a resource doesn't affect the status, a status event
// will still be sent.
if getGeneration(or1) != getGeneration(or2) {
return false
}
if or1.Error != nil && or2.Error != nil && or1.Error.Error() != or2.Error.Error() {
return false
}
if (or1.Error == nil && or2.Error != nil) || (or1.Error != nil && or2.Error == nil) {
return false
}
if len(or1.GeneratedResources) != len(or2.GeneratedResources) {
return false
}
for i := range or1.GeneratedResources {
if !ResourceStatusEqual(or1.GeneratedResources[i], or2.GeneratedResources[i]) {
return false
}
}
return true
}
func getGeneration(r *ResourceStatus) int64 {
if r.Resource == nil {
return 0
}
return r.Resource.GetGeneration()
}
@@ -0,0 +1,25 @@
// Code generated by "stringer -type=Type -linecomment"; DO NOT EDIT.
package event
import "strconv"
func _() {
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
var x [1]struct{}
_ = x[ResourceUpdateEvent-0]
_ = x[ErrorEvent-1]
_ = x[SyncEvent-2]
}
const _Type_name = "UpdateErrorSync"
var _Type_index = [...]uint8{0, 6, 11, 15}
func (i Type) String() string {
if i < 0 || i >= Type(len(_Type_index)-1) {
return "Type(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _Type_name[_Type_index[i]:_Type_index[i+1]]
}
@@ -0,0 +1,228 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package statusreaders
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
"github.com/fluxcd/cli-utils/pkg/object"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
)
// baseStatusReader is the implementation of the StatusReader interface defined
// in the engine package. It contains the basic logic needed for every resource.
// In order to handle resource specific logic, it must include an implementation
// of the resourceTypeStatusReader interface.
// In practice we will create many instances of baseStatusReader, each with a different
// implementation of the resourceTypeStatusReader interface and therefore each
// of the instances will be able to handle different resource types.
type baseStatusReader struct {
// mapper provides a way to look up the resource types that are available
// in the cluster.
mapper meta.RESTMapper
// resourceStatusReader is an resource-type specific implementation
// of the resourceTypeStatusReader interface. While the baseStatusReader
// contains the logic shared between all resource types, this implementation
// will contain the resource specific info.
resourceStatusReader resourceTypeStatusReader
}
// resourceTypeStatusReader is an interface that can be implemented differently
// for each resource type.
type resourceTypeStatusReader interface {
Supports(gk schema.GroupKind) bool
ReadStatusForObject(ctx context.Context, reader engine.ClusterReader, object *unstructured.Unstructured) (*event.ResourceStatus, error)
}
func (b *baseStatusReader) Supports(gk schema.GroupKind) bool {
return b.resourceStatusReader.Supports(gk)
}
// ReadStatus reads the object identified by the passed-in identifier and computes it's status. It reads
// the resource here, but computing status is delegated to the ReadStatusForObject function.
func (b *baseStatusReader) ReadStatus(ctx context.Context, reader engine.ClusterReader, identifier object.ObjMetadata) (*event.ResourceStatus, error) {
object, err := b.lookupResource(ctx, reader, identifier)
if err != nil {
return errIdentifierToResourceStatus(err, identifier)
}
return b.resourceStatusReader.ReadStatusForObject(ctx, reader, object)
}
// ReadStatusForObject computes the status for the passed-in object. Since this is specific for each
// resource type, the actual work is delegated to the implementation of the resourceTypeStatusReader interface.
func (b *baseStatusReader) ReadStatusForObject(ctx context.Context, reader engine.ClusterReader, object *unstructured.Unstructured) (*event.ResourceStatus, error) {
return b.resourceStatusReader.ReadStatusForObject(ctx, reader, object)
}
// lookupResource looks up a resource with the given identifier. It will use the rest mapper to resolve
// the version of the GroupKind given in the identifier.
// If the resource is found, it is returned. If it is not found or something
// went wrong, the function will return an error.
func (b *baseStatusReader) lookupResource(ctx context.Context, reader engine.ClusterReader, identifier object.ObjMetadata) (*unstructured.Unstructured, error) {
GVK, err := gvk(identifier.GroupKind, b.mapper)
if err != nil {
return nil, err
}
var u unstructured.Unstructured
u.SetGroupVersionKind(GVK)
key := types.NamespacedName{
Name: identifier.Name,
Namespace: identifier.Namespace,
}
err = reader.Get(ctx, key, &u)
if err != nil {
return nil, err
}
return &u, nil
}
// statusForGenResourcesFunc defines the function type used by the statusForGeneratedResource function.
// TODO: Find a better solution for this. Maybe put the logic for looking up generated resources
// into a separate type.
type statusForGenResourcesFunc func(ctx context.Context, mapper meta.RESTMapper, reader engine.ClusterReader, statusReader resourceTypeStatusReader,
object *unstructured.Unstructured, gk schema.GroupKind, selectorPath ...string) (event.ResourceStatuses, error)
// statusForGeneratedResources provides a way to fetch the statuses for all resources of a given GroupKind
// that match the selector in the provided resource. Typically, this is used to fetch the status of generated
// resources.
func statusForGeneratedResources(ctx context.Context, mapper meta.RESTMapper, reader engine.ClusterReader, statusReader resourceTypeStatusReader,
object *unstructured.Unstructured, gk schema.GroupKind, selectorPath ...string) (event.ResourceStatuses, error) {
selector, err := toSelector(object, selectorPath...)
if err != nil {
return event.ResourceStatuses{}, err
}
var objectList unstructured.UnstructuredList
gvk, err := gvk(gk, mapper)
if err != nil {
return event.ResourceStatuses{}, err
}
objectList.SetGroupVersionKind(gvk)
err = reader.ListNamespaceScoped(ctx, &objectList, object.GetNamespace(), selector)
if err != nil {
return event.ResourceStatuses{}, err
}
var resourceStatuses event.ResourceStatuses
for i := range objectList.Items {
generatedObject := objectList.Items[i]
resourceStatus, err := statusReader.ReadStatusForObject(ctx, reader, &generatedObject)
if err != nil {
return event.ResourceStatuses{}, err
}
resourceStatuses = append(resourceStatuses, resourceStatus)
}
sort.Sort(resourceStatuses)
return resourceStatuses, nil
}
// gvk looks up the GVK from a GroupKind using the rest mapper.
func gvk(gk schema.GroupKind, mapper meta.RESTMapper) (schema.GroupVersionKind, error) {
mapping, err := mapper.RESTMapping(gk)
if err != nil {
return schema.GroupVersionKind{}, err
}
return mapping.GroupVersionKind, nil
}
func toSelector(resource *unstructured.Unstructured, path ...string) (labels.Selector, error) {
selector, found, err := unstructured.NestedMap(resource.Object, path...)
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("no selector found")
}
bytes, err := json.Marshal(selector)
if err != nil {
return nil, err
}
var s metav1.LabelSelector
err = json.Unmarshal(bytes, &s)
if err != nil {
return nil, err
}
return metav1.LabelSelectorAsSelector(&s)
}
// errResourceToResourceStatus construct the appropriate ResourceStatus
// object based on an error and the resource itself.
func errResourceToResourceStatus(err error, resource *unstructured.Unstructured, genResources ...*event.ResourceStatus) (*event.ResourceStatus, error) {
// If the error is from the context, we don't attach that to the ResourceStatus,
// but just return it directly so the caller can decide how to handle this
// situation.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || isRateLimiterContextDeadlineExceeded(err) {
return nil, err
}
identifier := object.UnstructuredToObjMetadata(resource)
if apierrors.IsNotFound(err) {
return &event.ResourceStatus{
Identifier: identifier,
Status: status.NotFoundStatus,
Message: "Resource not found",
}, nil
}
return &event.ResourceStatus{
Identifier: identifier,
Status: status.UnknownStatus,
Resource: resource,
Error: err,
GeneratedResources: genResources,
}, nil
}
// errIdentifierToResourceStatus construct the appropriate ResourceStatus
// object based on an error and the identifier for a resource.
func errIdentifierToResourceStatus(err error, identifier object.ObjMetadata) (*event.ResourceStatus, error) {
// If the error is from the context, we don't attach that to the ResourceStatus,
// but just return it directly so the caller can decide how to handle this
// situation.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || isRateLimiterContextDeadlineExceeded(err) {
return nil, err
}
if apierrors.IsNotFound(err) {
return &event.ResourceStatus{
Identifier: identifier,
Status: status.NotFoundStatus,
Message: "Resource not found",
}, nil
}
return &event.ResourceStatus{
Identifier: identifier,
Status: status.UnknownStatus,
Error: err,
}, nil
}
// isRateLimiterContextDeadlineExceeded checks if the error is a rate limiter "would exceed context deadline" error
// this allows us to treat it the same way as the context.Canceled and context.DeadlineExceeded errors
// instead of attaching the error to the ResourceStatus, caller can decide how to handle this
func isRateLimiterContextDeadlineExceeded(err error) bool {
for {
next := errors.Unwrap(err)
if next == nil {
break
}
err = next
}
// there's no dedicated error type for this, hence we check the error message
// https://cs.opensource.google/go/x/time/+/refs/tags/v0.10.0:rate/rate.go;l=276
return err != nil && err.Error() == "rate: Wait(n=1) would exceed context deadline"
}
@@ -0,0 +1,86 @@
// Copyright 2021 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package statusreaders
import (
"context"
"fmt"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
"github.com/fluxcd/cli-utils/pkg/object"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
)
// NewDefaultStatusReader returns a DelegatingStatusReader that wraps a list of
// statusreaders to cover all built-in Kubernetes resources and other CRDs that
// follow known status conventions.
func NewDefaultStatusReader(mapper meta.RESTMapper) engine.StatusReader {
return NewStatusReader(mapper)
}
// NewStatusReader returns a DelegatingStatusReader that includes the statusreaders
// for the build-in Kubernetes resources and also any provided custom status readers.
func NewStatusReader(mapper meta.RESTMapper, statusReaders ...engine.StatusReader) engine.StatusReader {
defaultStatusReader := NewGenericStatusReader(mapper, status.Compute)
replicaSetStatusReader := NewReplicaSetStatusReader(mapper, defaultStatusReader)
deploymentStatusReader := NewDeploymentResourceReader(mapper, replicaSetStatusReader)
statefulSetStatusReader := NewStatefulSetResourceReader(mapper, defaultStatusReader)
statusReaders = append(statusReaders,
deploymentStatusReader,
statefulSetStatusReader,
replicaSetStatusReader,
defaultStatusReader,
)
return &DelegatingStatusReader{
StatusReaders: statusReaders,
}
}
type DelegatingStatusReader struct {
StatusReaders []engine.StatusReader
}
func (dsr *DelegatingStatusReader) Supports(gk schema.GroupKind) bool {
for _, sr := range dsr.StatusReaders {
if sr.Supports(gk) {
return true
}
}
return false
}
func (dsr *DelegatingStatusReader) ReadStatus(
ctx context.Context,
reader engine.ClusterReader,
id object.ObjMetadata,
) (*event.ResourceStatus, error) {
gk := id.GroupKind
for _, sr := range dsr.StatusReaders {
if sr.Supports(gk) {
return sr.ReadStatus(ctx, reader, id)
}
}
return nil, fmt.Errorf("no status reader supports this resource: %v", gk)
}
func (dsr *DelegatingStatusReader) ReadStatusForObject(
ctx context.Context,
reader engine.ClusterReader,
obj *unstructured.Unstructured,
) (*event.ResourceStatus, error) {
gk := obj.GroupVersionKind().GroupKind()
for _, sr := range dsr.StatusReaders {
if sr.Supports(gk) {
return sr.ReadStatusForObject(ctx, reader, obj)
}
}
return nil, fmt.Errorf("no status reader supports this resource: %v", gk)
}
@@ -0,0 +1,72 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package statusreaders
import (
"context"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
"github.com/fluxcd/cli-utils/pkg/object"
appsv1 "k8s.io/api/apps/v1"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
)
func NewDeploymentResourceReader(mapper meta.RESTMapper, rsStatusReader resourceTypeStatusReader) engine.StatusReader {
return &baseStatusReader{
mapper: mapper,
resourceStatusReader: &deploymentResourceReader{
mapper: mapper,
rsStatusReader: rsStatusReader,
},
}
}
// deploymentResourceReader is a resourceTypeStatusReader that can fetch Deployment
// resources from the cluster, knows how to find any ReplicaSets belonging to the
// Deployment, and compute status for the deployment.
type deploymentResourceReader struct {
mapper meta.RESTMapper
// rsStatusReader is the implementation of the resourceTypeStatusReader
// the knows how to compute the status for ReplicaSets.
rsStatusReader resourceTypeStatusReader
}
var _ resourceTypeStatusReader = &deploymentResourceReader{}
func (d *deploymentResourceReader) Supports(gk schema.GroupKind) bool {
return gk == appsv1.SchemeGroupVersion.WithKind("Deployment").GroupKind()
}
func (d *deploymentResourceReader) ReadStatusForObject(ctx context.Context, reader engine.ClusterReader,
deployment *unstructured.Unstructured) (*event.ResourceStatus, error) {
identifier := object.UnstructuredToObjMetadata(deployment)
replicaSetStatuses, err := statusForGeneratedResources(ctx, d.mapper, reader, d.rsStatusReader, deployment,
appsv1.SchemeGroupVersion.WithKind("ReplicaSet").GroupKind(), "spec", "selector")
if err != nil {
return errResourceToResourceStatus(err, deployment)
}
// Currently this engine just uses the status library for computing
// status for the deployment. But we do have the status and state for all
// ReplicaSets and Pods in the ObservedReplicaSets data structure, so the
// rules can be improved to take advantage of this information.
res, err := status.Compute(deployment)
if err != nil {
return errResourceToResourceStatus(err, deployment, replicaSetStatuses...)
}
return &event.ResourceStatus{
Identifier: identifier,
Status: res.Status,
Resource: deployment,
Message: res.Message,
GeneratedResources: replicaSetStatuses,
}, nil
}
@@ -0,0 +1,65 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package statusreaders
import (
"context"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
"github.com/fluxcd/cli-utils/pkg/object"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
)
// StatusFunc returns the status of the given object. This func is passed into
// NewGenericStatusReader so that the returned StatusReader can be used for custom types.
// An example of a StatusFunc is status.Compute.
type StatusFunc func(u *unstructured.Unstructured) (*status.Result, error)
func NewGenericStatusReader(mapper meta.RESTMapper, statusFunc StatusFunc) engine.StatusReader {
return &baseStatusReader{
mapper: mapper,
resourceStatusReader: &genericStatusReader{
mapper: mapper,
statusFunc: statusFunc,
},
}
}
// genericStatusReader is a resourceTypeStatusReader that will be used for
// any resource that doesn't have a specific engine. It will just delegate
// computation of status to the status library.
// This should work pretty well for resources that doesn't have any
// generated resources and where status can be computed only based on the
// resource itself.
type genericStatusReader struct {
mapper meta.RESTMapper
statusFunc StatusFunc
}
var _ resourceTypeStatusReader = &genericStatusReader{}
func (g *genericStatusReader) Supports(schema.GroupKind) bool {
return true
}
func (g *genericStatusReader) ReadStatusForObject(_ context.Context, _ engine.ClusterReader, resource *unstructured.Unstructured) (*event.ResourceStatus, error) {
identifier := object.UnstructuredToObjMetadata(resource)
res, err := g.statusFunc(resource)
if err != nil {
return errResourceToResourceStatus(err, resource)
}
return &event.ResourceStatus{
Identifier: identifier,
Status: res.Status,
Resource: resource,
Message: res.Message,
}, nil
}
@@ -0,0 +1,140 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package statusreaders
import (
"context"
"fmt"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
"github.com/fluxcd/cli-utils/pkg/object"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
)
func newPodControllerStatusReader(mapper meta.RESTMapper, podStatusReader resourceTypeStatusReader) *podControllerStatusReader {
return &podControllerStatusReader{
mapper: mapper,
podStatusReader: podStatusReader,
groupKind: schema.GroupKind{
Group: "",
Kind: "Pod",
},
statusFunc: status.Compute,
statusForGenResourcesFunc: statusForGeneratedResources,
}
}
// podControllerStatusReader encapsulates the logic needed to compute the status
// for resource types that act as controllers for pods. This is quite common, so
// the logic is here instead of duplicated in each resource specific StatusReader.
type podControllerStatusReader struct {
mapper meta.RESTMapper
podStatusReader resourceTypeStatusReader
groupKind schema.GroupKind
statusFunc func(u *unstructured.Unstructured) (*status.Result, error)
// TODO(mortent): See if we can avoid this. For now it is useful for testing.
statusForGenResourcesFunc statusForGenResourcesFunc
}
func (p *podControllerStatusReader) readStatus(ctx context.Context, reader engine.ClusterReader, obj *unstructured.Unstructured) (*event.ResourceStatus, error) {
identifier := object.UnstructuredToObjMetadata(obj)
podResourceStatuses, err := p.statusForGenResourcesFunc(ctx, p.mapper, reader, p.podStatusReader, obj,
p.groupKind, "spec", "selector")
if err != nil {
return errResourceToResourceStatus(err, obj)
}
res, err := p.statusFunc(obj)
if err != nil {
return errResourceToResourceStatus(err, obj, podResourceStatuses...)
}
// If the status comes back as pending, we take a look at the pods to make sure
// none of them have terminally failed. Pods that are pending scheduling are
// excluded, as this is a transient state that cluster autoscalers can resolve.
// Pods that are being deleted (e.g. during a rolling update) are also excluded.
if res.Status == status.InProgressStatus {
var failedPods []*event.ResourceStatus
for _, podResourceStatus := range podResourceStatuses {
if podResourceStatus.Status == status.FailedStatus {
if isTransientPodFailure(podResourceStatus) {
continue
}
failedPods = append(failedPods, podResourceStatus)
}
}
if len(failedPods) > 0 {
return &event.ResourceStatus{
Identifier: identifier,
Status: status.FailedStatus,
Resource: obj,
Message: fmt.Sprintf("%d pods have failed", len(failedPods)),
GeneratedResources: podResourceStatuses,
}, nil
}
}
return &event.ResourceStatus{
Identifier: identifier,
Status: res.Status,
Resource: obj,
Message: res.Message,
GeneratedResources: podResourceStatuses,
}, nil
}
// isTransientPodFailure returns true if the pod's failure is likely transient
// and should not cause the parent controller to be marked as failed. This
// includes pods that are pending scheduling (which an autoscaler may resolve)
// and pods that are being deleted (during a rolling update).
func isTransientPodFailure(podStatus *event.ResourceStatus) bool {
pod := podStatus.Resource
if pod == nil {
// If the resource is not available, we cannot determine whether the
// failure is transient. Treat it as transient to avoid prematurely
// marking the parent controller as failed.
return true
}
// Pods being deleted are expected during rolling updates.
if pod.GetDeletionTimestamp() != nil {
return true
}
// Pods that are pending scheduling due to insufficient resources are
// transient failures that a cluster autoscaler can resolve.
if isPodUnschedulable(pod) {
return true
}
return false
}
// isPodUnschedulable returns true if the object is a pod with a PodScheduled
// condition indicating it is Unschedulable.
func isPodUnschedulable(obj *unstructured.Unstructured) bool {
gk := obj.GroupVersionKind().GroupKind()
if gk != (schema.GroupKind{Kind: "Pod"}) {
return false
}
objWithConditions, err := status.GetObjectWithConditions(obj.Object)
if err != nil {
return false
}
for _, cond := range objWithConditions.Status.Conditions {
if cond.Type == string(corev1.PodScheduled) &&
cond.Status == corev1.ConditionFalse &&
cond.Reason == corev1.PodReasonUnschedulable {
return true
}
}
return false
}
@@ -0,0 +1,44 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package statusreaders
import (
"context"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
appsv1 "k8s.io/api/apps/v1"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
)
func NewReplicaSetStatusReader(mapper meta.RESTMapper, podStatusReader resourceTypeStatusReader) engine.StatusReader {
return &baseStatusReader{
mapper: mapper,
resourceStatusReader: &replicaSetStatusReader{
mapper: mapper,
podStatusReader: podStatusReader,
},
}
}
// replicaSetStatusReader is an engine that can fetch ReplicaSet resources
// from the cluster, knows how to find any Pods belonging to the ReplicaSet,
// and compute status for the ReplicaSet.
type replicaSetStatusReader struct {
mapper meta.RESTMapper
podStatusReader resourceTypeStatusReader
}
var _ resourceTypeStatusReader = &replicaSetStatusReader{}
func (r *replicaSetStatusReader) Supports(gk schema.GroupKind) bool {
return gk == appsv1.SchemeGroupVersion.WithKind("ReplicaSet").GroupKind()
}
func (r *replicaSetStatusReader) ReadStatusForObject(ctx context.Context, reader engine.ClusterReader, rs *unstructured.Unstructured) (*event.ResourceStatus, error) {
return newPodControllerStatusReader(r.mapper, r.podStatusReader).readStatus(ctx, reader, rs)
}
@@ -0,0 +1,45 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package statusreaders
import (
"context"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
appsv1 "k8s.io/api/apps/v1"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
)
func NewStatefulSetResourceReader(mapper meta.RESTMapper, podResourceReader resourceTypeStatusReader) engine.StatusReader {
return &baseStatusReader{
mapper: mapper,
resourceStatusReader: &statefulSetResourceReader{
mapper: mapper,
podResourceReader: podResourceReader,
},
}
}
// statefulSetResourceReader is an implementation of the ResourceReader interface
// that can fetch StatefulSet resources from the cluster, knows how to find any
// Pods belonging to the StatefulSet, and compute status for the StatefulSet.
type statefulSetResourceReader struct {
mapper meta.RESTMapper
podResourceReader resourceTypeStatusReader
}
var _ resourceTypeStatusReader = &statefulSetResourceReader{}
func (s *statefulSetResourceReader) Supports(gk schema.GroupKind) bool {
return gk == appsv1.SchemeGroupVersion.WithKind("StatefulSet").GroupKind()
}
func (s *statefulSetResourceReader) ReadStatusForObject(ctx context.Context, reader engine.ClusterReader,
statefulSet *unstructured.Unstructured) (*event.ResourceStatus, error) {
return newPodControllerStatusReader(s.mapper, s.podResourceReader).readStatus(ctx, reader, statefulSet)
}
+627
View File
@@ -0,0 +1,627 @@
// Copyright 2019 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package status
import (
"fmt"
"math"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// GetConditionsFn defines the signature for functions to compute the
// status of a built-in resource.
type GetConditionsFn func(*unstructured.Unstructured) (*Result, error)
// legacyTypes defines the mapping from GroupKind to a function that can
// compute the status for the given resource.
var legacyTypes = map[string]GetConditionsFn{
"Service": serviceConditions,
"Pod": podConditions,
"Secret": alwaysReady,
"PersistentVolumeClaim": pvcConditions,
"apps/StatefulSet": stsConditions,
"apps/DaemonSet": daemonsetConditions,
"extensions/DaemonSet": daemonsetConditions,
"apps/Deployment": deploymentConditions,
"extensions/Deployment": deploymentConditions,
"apps/ReplicaSet": replicasetConditions,
"extensions/ReplicaSet": replicasetConditions,
"policy/PodDisruptionBudget": pdbConditions,
"batch/CronJob": alwaysReady,
"ConfigMap": alwaysReady,
"batch/Job": jobConditions,
"apiextensions.k8s.io/CustomResourceDefinition": crdConditions,
}
const (
tooFewReady = "LessReady"
tooFewAvailable = "LessAvailable"
tooFewUpdated = "LessUpdated"
tooFewReplicas = "LessReplicas"
extraPods = "ExtraPods"
onDeleteUpdateStrategy = "OnDelete"
// How long a pod can be unscheduled before it is reported as
// unschedulable.
ScheduleWindow = 15 * time.Second
)
// GetLegacyConditionsFn returns a function that can compute the status for the
// given resource, or nil if the resource type is not known.
func GetLegacyConditionsFn(u *unstructured.Unstructured) GetConditionsFn {
gvk := u.GroupVersionKind()
g := gvk.Group
k := gvk.Kind
key := g + "/" + k
if g == "" {
key = k
}
return legacyTypes[key]
}
// alwaysReady Used for resources that are always ready
func alwaysReady(u *unstructured.Unstructured) (*Result, error) {
return &Result{
Status: CurrentStatus,
Message: "Resource is always ready",
Conditions: []Condition{},
}, nil
}
// stsConditions return standardized Conditions for Statefulset
//
// StatefulSet does define the .status.conditions property, but the controller never
// actually sets any Conditions. Thus, status must be computed only based on the other
// properties under .status. We don't have any way to find out if a reconcile for a
// StatefulSet has failed.
func stsConditions(u *unstructured.Unstructured) (*Result, error) {
obj := u.UnstructuredContent()
// updateStrategy==ondelete is a user managed statefulset.
updateStrategy := GetStringField(obj, ".spec.updateStrategy.type", "")
if updateStrategy == onDeleteUpdateStrategy {
return &Result{
Status: CurrentStatus,
Message: "StatefulSet is using the ondelete update strategy",
Conditions: []Condition{},
}, nil
}
// Replicas
specReplicas := GetIntField(obj, ".spec.replicas", 1)
readyReplicas := GetIntField(obj, ".status.readyReplicas", 0)
currentReplicas := GetIntField(obj, ".status.currentReplicas", 0)
updatedReplicas := GetIntField(obj, ".status.updatedReplicas", 0)
statusReplicas := GetIntField(obj, ".status.replicas", 0)
partition := GetIntField(obj, ".spec.updateStrategy.rollingUpdate.partition", -1)
if specReplicas > statusReplicas {
message := fmt.Sprintf("Replicas: %d/%d", statusReplicas, specReplicas)
return newInProgressStatus(tooFewReplicas, message), nil
}
if specReplicas > readyReplicas {
message := fmt.Sprintf("Ready: %d/%d", readyReplicas, specReplicas)
return newInProgressStatus(tooFewReady, message), nil
}
if statusReplicas > specReplicas {
message := fmt.Sprintf("Pending termination: %d", statusReplicas-specReplicas)
return newInProgressStatus(extraPods, message), nil
}
// https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#partitions
if partition != -1 {
if updatedReplicas < (specReplicas - partition) {
message := fmt.Sprintf("updated: %d/%d", updatedReplicas, specReplicas-partition)
return newInProgressStatus("PartitionRollout", message), nil
}
// Partition case All ok
return &Result{
Status: CurrentStatus,
Message: fmt.Sprintf("Partition rollout complete. updated: %d", updatedReplicas),
Conditions: []Condition{},
}, nil
}
if specReplicas > currentReplicas {
message := fmt.Sprintf("current: %d/%d", currentReplicas, specReplicas)
return newInProgressStatus("LessCurrent", message), nil
}
// Revision
currentRevision := GetStringField(obj, ".status.currentRevision", "")
updatedRevision := GetStringField(obj, ".status.updateRevision", "")
if currentRevision != updatedRevision {
message := "Waiting for updated revision to match current"
return newInProgressStatus("RevisionMismatch", message), nil
}
// All ok
return &Result{
Status: CurrentStatus,
Message: fmt.Sprintf("All replicas scheduled as expected. Replicas: %d", statusReplicas),
Conditions: []Condition{},
}, nil
}
// deploymentConditions return standardized Conditions for Deployment.
//
// For Deployments, we look at .status.conditions as well as the other properties
// under .status. Status will be Failed if the progress deadline has been exceeded.
func deploymentConditions(u *unstructured.Unstructured) (*Result, error) {
obj := u.UnstructuredContent()
progressing := false
// Check if progressDeadlineSeconds is set. If not, the controller will not set
// the `Progressing` condition, so it will always consider a deployment to be
// progressing. The use of math.MaxInt32 is due to special handling in the
// controller:
// https://github.com/kubernetes/kubernetes/blob/a3ccea9d8743f2ff82e41b6c2af6dc2c41dc7b10/pkg/controller/deployment/util/deployment_util.go#L886
progressDeadline := GetIntField(obj, ".spec.progressDeadlineSeconds", math.MaxInt32)
if progressDeadline == math.MaxInt32 {
progressing = true
}
available := false
objc, err := GetObjectWithConditions(obj)
if err != nil {
return nil, err
}
for _, c := range objc.Status.Conditions {
switch c.Type {
case "Progressing": // appsv1.DeploymentProgressing:
// https://github.com/kubernetes/kubernetes/blob/a3ccea9d8743f2ff82e41b6c2af6dc2c41dc7b10/pkg/controller/deployment/progress.go#L52
if c.Reason == "ProgressDeadlineExceeded" {
return &Result{
Status: FailedStatus,
Message: "Progress deadline exceeded",
Conditions: []Condition{{ConditionStalled, corev1.ConditionTrue, c.Reason, c.Message}},
}, nil
}
if c.Status == corev1.ConditionTrue && c.Reason == "NewReplicaSetAvailable" {
progressing = true
}
case "Available": // appsv1.DeploymentAvailable:
if c.Status == corev1.ConditionTrue {
available = true
}
}
}
// replicas
specReplicas := GetIntField(obj, ".spec.replicas", 1) // Controller uses 1 as default if not specified.
statusReplicas := GetIntField(obj, ".status.replicas", 0)
updatedReplicas := GetIntField(obj, ".status.updatedReplicas", 0)
readyReplicas := GetIntField(obj, ".status.readyReplicas", 0)
availableReplicas := GetIntField(obj, ".status.availableReplicas", 0)
// TODO spec.replicas zero case ??
if specReplicas > statusReplicas {
message := fmt.Sprintf("Replicas: %d/%d", statusReplicas, specReplicas)
return newInProgressStatus(tooFewReplicas, message), nil
}
if specReplicas > updatedReplicas {
message := fmt.Sprintf("Updated: %d/%d", updatedReplicas, specReplicas)
return newInProgressStatus(tooFewUpdated, message), nil
}
if statusReplicas > specReplicas {
message := fmt.Sprintf("Pending termination: %d", statusReplicas-specReplicas)
return newInProgressStatus(extraPods, message), nil
}
if updatedReplicas > availableReplicas {
message := fmt.Sprintf("Available: %d/%d", availableReplicas, updatedReplicas)
return newInProgressStatus(tooFewAvailable, message), nil
}
if specReplicas > readyReplicas {
message := fmt.Sprintf("Ready: %d/%d", readyReplicas, specReplicas)
return newInProgressStatus(tooFewReady, message), nil
}
// check conditions
if !progressing {
message := "ReplicaSet not Available"
return newInProgressStatus("ReplicaSetNotAvailable", message), nil
}
if !available {
message := "Deployment not Available"
return newInProgressStatus("DeploymentNotAvailable", message), nil
}
// All ok
return &Result{
Status: CurrentStatus,
Message: fmt.Sprintf("Deployment is available. Replicas: %d", statusReplicas),
Conditions: []Condition{},
}, nil
}
// replicasetConditions return standardized Conditions for Replicaset
func replicasetConditions(u *unstructured.Unstructured) (*Result, error) {
obj := u.UnstructuredContent()
// Conditions
objc, err := GetObjectWithConditions(obj)
if err != nil {
return nil, err
}
for _, c := range objc.Status.Conditions {
// https://github.com/kubernetes/kubernetes/blob/a3ccea9d8743f2ff82e41b6c2af6dc2c41dc7b10/pkg/controller/replicaset/replica_set_utils.go
if c.Type == "ReplicaFailure" && c.Status == corev1.ConditionTrue {
message := "Replica Failure condition. Check Pods"
return newInProgressStatus("ReplicaFailure", message), nil
}
}
// Replicas
specReplicas := GetIntField(obj, ".spec.replicas", 1) // Controller uses 1 as default if not specified.
statusReplicas := GetIntField(obj, ".status.replicas", 0)
readyReplicas := GetIntField(obj, ".status.readyReplicas", 0)
availableReplicas := GetIntField(obj, ".status.availableReplicas", 0)
fullyLabelledReplicas := GetIntField(obj, ".status.fullyLabeledReplicas", 0)
if specReplicas > fullyLabelledReplicas {
message := fmt.Sprintf("Labelled: %d/%d", fullyLabelledReplicas, specReplicas)
return newInProgressStatus("LessLabelled", message), nil
}
if specReplicas > availableReplicas {
message := fmt.Sprintf("Available: %d/%d", availableReplicas, specReplicas)
return newInProgressStatus(tooFewAvailable, message), nil
}
if specReplicas > readyReplicas {
message := fmt.Sprintf("Ready: %d/%d", readyReplicas, specReplicas)
return newInProgressStatus(tooFewReady, message), nil
}
if statusReplicas > specReplicas {
message := fmt.Sprintf("Pending termination: %d", statusReplicas-specReplicas)
return newInProgressStatus(extraPods, message), nil
}
// All ok
return &Result{
Status: CurrentStatus,
Message: fmt.Sprintf("ReplicaSet is available. Replicas: %d", statusReplicas),
Conditions: []Condition{},
}, nil
}
// daemonsetConditions return standardized Conditions for DaemonSet
func daemonsetConditions(u *unstructured.Unstructured) (*Result, error) {
// We check that the latest generation is equal to observed generation as
// part of checking generic properties but in that case, we are lenient and
// skip the check if those fields are unset. For daemonset, we know that if
// the daemonset controller has acted on a resource, these fields would not
// be unset. So, we ensure that here.
res, err := checkGenerationSet(u)
if err != nil || res != nil {
return res, err
}
obj := u.UnstructuredContent()
// replicas
desiredNumberScheduled := GetIntField(obj, ".status.desiredNumberScheduled", -1)
currentNumberScheduled := GetIntField(obj, ".status.currentNumberScheduled", 0)
updatedNumberScheduled := GetIntField(obj, ".status.updatedNumberScheduled", 0)
numberAvailable := GetIntField(obj, ".status.numberAvailable", 0)
numberReady := GetIntField(obj, ".status.numberReady", 0)
if desiredNumberScheduled == -1 {
message := "Missing .status.desiredNumberScheduled"
return newInProgressStatus("NoDesiredNumber", message), nil
}
if desiredNumberScheduled > currentNumberScheduled {
message := fmt.Sprintf("Current: %d/%d", currentNumberScheduled, desiredNumberScheduled)
return newInProgressStatus("LessCurrent", message), nil
}
if desiredNumberScheduled > updatedNumberScheduled {
message := fmt.Sprintf("Updated: %d/%d", updatedNumberScheduled, desiredNumberScheduled)
return newInProgressStatus(tooFewUpdated, message), nil
}
if desiredNumberScheduled > numberAvailable {
message := fmt.Sprintf("Available: %d/%d", numberAvailable, desiredNumberScheduled)
return newInProgressStatus(tooFewAvailable, message), nil
}
if desiredNumberScheduled > numberReady {
message := fmt.Sprintf("Ready: %d/%d", numberReady, desiredNumberScheduled)
return newInProgressStatus(tooFewReady, message), nil
}
// All ok
return &Result{
Status: CurrentStatus,
Message: fmt.Sprintf("All replicas scheduled as expected. Replicas: %d", desiredNumberScheduled),
Conditions: []Condition{},
}, nil
}
// checkGenerationSet checks that the metadata.generation and
// status.observedGeneration fields are set.
func checkGenerationSet(u *unstructured.Unstructured) (*Result, error) {
_, found, err := unstructured.NestedInt64(u.Object, "metadata", "generation")
if err != nil {
return nil, fmt.Errorf("looking up metadata.generation from resource: %w", err)
}
if !found {
message := fmt.Sprintf("%s metadata.generation not found", u.GetKind())
return &Result{
Status: InProgressStatus,
Message: message,
Conditions: []Condition{newReconcilingCondition("NoGeneration", message)},
}, nil
}
_, found, err = unstructured.NestedInt64(u.Object, "status", "observedGeneration")
if err != nil {
return nil, fmt.Errorf("looking up status.observedGeneration from resource: %w", err)
}
if !found {
message := fmt.Sprintf("%s status.observedGeneration not found", u.GetKind())
return &Result{
Status: InProgressStatus,
Message: message,
Conditions: []Condition{newReconcilingCondition("NoObservedGeneration", message)},
}, nil
}
return nil, nil
}
// pvcConditions return standardized Conditions for PVC
func pvcConditions(u *unstructured.Unstructured) (*Result, error) {
obj := u.UnstructuredContent()
phase := GetStringField(obj, ".status.phase", "unknown")
if phase != "Bound" { // corev1.ClaimBound
message := fmt.Sprintf("PVC is not Bound. phase: %s", phase)
return newInProgressStatus("NotBound", message), nil
}
// All ok
return &Result{
Status: CurrentStatus,
Message: "PVC is Bound",
Conditions: []Condition{},
}, nil
}
// podConditions return standardized Conditions for Pod
func podConditions(u *unstructured.Unstructured) (*Result, error) {
obj := u.UnstructuredContent()
objc, err := GetObjectWithConditions(obj)
if err != nil {
return nil, err
}
phase := GetStringField(obj, ".status.phase", "")
switch phase {
case "Succeeded":
return &Result{
Status: CurrentStatus,
Message: "Pod has completed successfully",
Conditions: []Condition{},
}, nil
case "Failed":
return &Result{
Status: CurrentStatus,
Message: "Pod has completed, but not successfully",
Conditions: []Condition{},
}, nil
case "Running":
if hasConditionWithStatus(objc.Status.Conditions, "Ready", corev1.ConditionTrue) {
return &Result{
Status: CurrentStatus,
Message: "Pod is Ready",
Conditions: []Condition{},
}, nil
}
containerNames, isCrashLooping, err := getCrashLoopingContainers(obj)
if err != nil {
return nil, err
}
if isCrashLooping {
return newFailedStatus("ContainerCrashLooping",
fmt.Sprintf("Containers in CrashLoop state: %s", strings.Join(containerNames, ","))), nil
}
return newInProgressStatus("PodRunningNotReady", "Pod is running but is not Ready"), nil
case "Pending":
c, found := getConditionWithStatus(objc.Status.Conditions, "PodScheduled", corev1.ConditionFalse)
if found && c.Reason == "Unschedulable" {
if time.Now().Add(-ScheduleWindow).Before(u.GetCreationTimestamp().Time) {
// We give the pod 15 seconds to be scheduled before we report it
// as unschedulable.
return newInProgressStatus("PodNotScheduled", "Pod has not been scheduled"), nil
}
return newFailedStatus("PodUnschedulable", "Pod could not be scheduled"), nil
}
return newInProgressStatus("PodPending", "Pod is in the Pending phase"), nil
default:
// If the controller hasn't observed the pod yet, there is no phase. We consider this as it
// still being in progress.
if phase == "" {
return newInProgressStatus("PodNotObserved", "Pod phase not available"), nil
}
return nil, fmt.Errorf("unknown phase %s", phase)
}
}
func getCrashLoopingContainers(obj map[string]interface{}) ([]string, bool, error) {
var containerNames []string
css, found, err := unstructured.NestedSlice(obj, "status", "containerStatuses")
if !found || err != nil {
return containerNames, found, err
}
for _, item := range css {
cs := item.(map[string]interface{})
n, found := cs["name"]
if !found {
continue
}
name := n.(string)
s, found := cs["state"]
if !found {
continue
}
state := s.(map[string]interface{})
ws, found := state["waiting"]
if !found {
continue
}
waitingState := ws.(map[string]interface{})
r, found := waitingState["reason"]
if !found {
continue
}
reason := r.(string)
if reason == "CrashLoopBackOff" {
containerNames = append(containerNames, name)
}
}
if len(containerNames) > 0 {
return containerNames, true, nil
}
return containerNames, false, nil
}
// pdbConditions computes the status for PodDisruptionBudgets. A PDB
// is currently considered Current if the disruption controller has
// observed the latest version of the PDB resource and has computed
// the AllowedDisruptions. PDBs do have ObservedGeneration in the
// Status object, so if this function gets called we know that
// the controller has observed the latest changes.
// The disruption controller does not set any conditions if
// computing the AllowedDisruptions fails (and there are many ways
// it can fail), but there is PR against OSS Kubernetes to address
// this: https://github.com/kubernetes/kubernetes/pull/86929
func pdbConditions(_ *unstructured.Unstructured) (*Result, error) {
// All ok
return &Result{
Status: CurrentStatus,
Message: "AllowedDisruptions has been computed.",
Conditions: []Condition{},
}, nil
}
// jobConditions return standardized Conditions for Job
//
// A job will have the InProgress status until it starts running. Then it will have the Current
// status while the job is running and after it has been completed successfully. It
// will have the Failed status if it the job has failed.
func jobConditions(u *unstructured.Unstructured) (*Result, error) {
obj := u.UnstructuredContent()
parallelism := GetIntField(obj, ".spec.parallelism", 1)
completions := GetIntField(obj, ".spec.completions", parallelism)
succeeded := GetIntField(obj, ".status.succeeded", 0)
active := GetIntField(obj, ".status.active", 0)
failed := GetIntField(obj, ".status.failed", 0)
starttime := GetStringField(obj, ".status.startTime", "")
// Conditions
// https://github.com/kubernetes/kubernetes/blob/master/pkg/controller/job/utils.go#L24
objc, err := GetObjectWithConditions(obj)
if err != nil {
return nil, err
}
for _, c := range objc.Status.Conditions {
switch c.Type {
case "Complete":
if c.Status == corev1.ConditionTrue {
message := fmt.Sprintf("Job Completed. succeeded: %d/%d", succeeded, completions)
return &Result{
Status: CurrentStatus,
Message: message,
Conditions: []Condition{},
}, nil
}
case "Failed":
if c.Status == corev1.ConditionTrue {
return newFailedStatus("JobFailed",
fmt.Sprintf("Job Failed. failed: %d/%d", failed, completions)), nil
}
}
}
// replicas
if starttime == "" {
message := "Job not started"
return newInProgressStatus("JobNotStarted", message), nil
}
return &Result{
Status: CurrentStatus,
Message: fmt.Sprintf("Job in progress. success:%d, active: %d, failed: %d", succeeded, active, failed),
Conditions: []Condition{},
}, nil
}
// serviceConditions return standardized Conditions for Service
func serviceConditions(u *unstructured.Unstructured) (*Result, error) {
obj := u.UnstructuredContent()
specType := GetStringField(obj, ".spec.type", "ClusterIP")
specClusterIP := GetStringField(obj, ".spec.clusterIP", "")
if specType == "LoadBalancer" {
if specClusterIP == "" {
message := "ClusterIP not set. Service type: LoadBalancer"
return newInProgressStatus("NoIPAssigned", message), nil
}
}
return &Result{
Status: CurrentStatus,
Message: "Service is ready",
Conditions: []Condition{},
}, nil
}
func crdConditions(u *unstructured.Unstructured) (*Result, error) {
obj := u.UnstructuredContent()
objc, err := GetObjectWithConditions(obj)
if err != nil {
return nil, err
}
for _, c := range objc.Status.Conditions {
if c.Type == "NamesAccepted" && c.Status == corev1.ConditionFalse {
return newFailedStatus(c.Reason, c.Message), nil
}
if c.Type == "Established" {
if c.Status == corev1.ConditionFalse && c.Reason != "Installing" {
return newFailedStatus(c.Reason, c.Message), nil
}
if c.Status == corev1.ConditionTrue {
return &Result{
Status: CurrentStatus,
Message: "CRD is established",
Conditions: []Condition{},
}, nil
}
}
}
return newInProgressStatus("Installing", "Install in progress"), nil
}
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2019 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
// Package kstatus contains functionality for computing the status
// of Kubernetes resources.
//
// The statuses defined in this package are:
// - InProgress
// - Current
// - Failed
// - Terminating
// - NotFound
// - Unknown
//
// Computing the status of a resources can be done by calling the
// Compute function in the status package.
//
// import (
// "github.com/fluxcd/cli-utils/pkg/kstatus/status"
// )
//
// res, err := status.Compute(resource)
//
// The package also defines a set of new conditions:
// - InProgress
// - Failed
//
// These conditions have been chosen to follow the
// "abnormal-true" pattern where conditions should be set to true
// for error/abnormal conditions and the absence of a condition means
// things are normal.
//
// The Augment function augments any unstructured resource with
// the standard conditions described above. The values of
// these conditions are decided based on other status information
// available in the resources.
//
// import (
// "github.com/fluxcd/cli-utils/pkg/kstatus/status
// )
//
// err := status.Augment(resource)
package status
+99
View File
@@ -0,0 +1,99 @@
// Copyright 2019 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package status
import (
"fmt"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// checkGenericProperties looks at the properties that are available on
// all or most of the Kubernetes resources. If a decision can be made based
// on this information, there is no need to look at the resource-specidic
// rules.
// This also checks for the presence of the conditions defined in this package.
// If any of these are set on the resource, a decision is made solely based
// on this and none of the resource specific rules will be used. The goal here
// is that if controllers, built-in or custom, use these conditions, we can easily
// find status of resources.
func checkGenericProperties(u *unstructured.Unstructured) (*Result, error) {
obj := u.UnstructuredContent()
// Check if the resource is scheduled for deletion
deletionTimestamp, found, err := unstructured.NestedString(obj, "metadata", "deletionTimestamp")
if err != nil {
return nil, fmt.Errorf("looking up metadata.deletionTimestamp from resource: %w", err)
}
if found && deletionTimestamp != "" {
return &Result{
Status: TerminatingStatus,
Message: "Resource scheduled for deletion",
Conditions: []Condition{},
}, nil
}
res, err := checkGeneration(u)
if res != nil || err != nil {
return res, err
}
// Check if the resource has any of the standard conditions. If so, we just use them
// and no need to look at anything else.
objWithConditions, err := GetObjectWithConditions(obj)
if err != nil {
return nil, err
}
for _, cond := range objWithConditions.Status.Conditions {
if cond.Type == string(ConditionReconciling) && cond.Status == corev1.ConditionTrue {
return newInProgressStatus(cond.Reason, cond.Message), nil
}
if cond.Type == string(ConditionStalled) && cond.Status == corev1.ConditionTrue {
return &Result{
Status: FailedStatus,
Message: cond.Message,
Conditions: []Condition{
{
Type: ConditionStalled,
Status: corev1.ConditionTrue,
Reason: cond.Reason,
Message: cond.Message,
},
},
}, nil
}
}
return nil, nil
}
func checkGeneration(u *unstructured.Unstructured) (*Result, error) {
// ensure that the meta generation is observed
generation, found, err := unstructured.NestedInt64(u.Object, "metadata", "generation")
if err != nil {
return nil, fmt.Errorf("looking up metadata.generation from resource: %w", err)
}
if !found {
return nil, nil
}
observedGeneration, found, err := unstructured.NestedInt64(u.Object, "status", "observedGeneration")
if err != nil {
return nil, fmt.Errorf("looking up status.observedGeneration from resource: %w", err)
}
if found {
// Resource does not have this field, so we can't do this check.
// TODO(mortent): Verify behavior of not set vs does not exist.
if observedGeneration != generation {
message := fmt.Sprintf("%s generation is %d, but latest observed generation is %d", u.GetKind(), generation, observedGeneration)
return &Result{
Status: InProgressStatus,
Message: message,
Conditions: []Condition{newReconcilingCondition("LatestGenerationNotObserved", message)},
}, nil
}
}
return nil, nil
}
+242
View File
@@ -0,0 +1,242 @@
// Copyright 2019 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package status
import (
"errors"
"fmt"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
const (
// The set of standard conditions defined in this package. These follow the "abnormality-true"
// convention where conditions should have a true value for abnormal/error situations and the absence
// of a condition should be interpreted as a false value, i.e. everything is normal.
ConditionStalled ConditionType = "Stalled"
ConditionReconciling ConditionType = "Reconciling"
// The set of status conditions which can be assigned to resources.
InProgressStatus Status = "InProgress"
FailedStatus Status = "Failed"
CurrentStatus Status = "Current"
TerminatingStatus Status = "Terminating"
NotFoundStatus Status = "NotFound"
UnknownStatus Status = "Unknown"
)
var (
Statuses = []Status{InProgressStatus, FailedStatus, CurrentStatus, TerminatingStatus, UnknownStatus}
)
// ConditionType defines the set of condition types allowed inside a Condition struct.
type ConditionType string
// String returns the ConditionType as a string.
func (c ConditionType) String() string {
return string(c)
}
// Status defines the set of statuses a resource can have.
type Status string
// String returns the status as a string.
func (s Status) String() string {
return string(s)
}
// StatusFromString turns a string into a Status. Will panic if the provided string is
// not a valid status.
func FromStringOrDie(text string) Status {
s := Status(text)
for _, r := range Statuses {
if s == r {
return s
}
}
panic(fmt.Errorf("string has invalid status: %s", s))
}
// Result contains the results of a call to compute the status of
// a resource.
type Result struct {
// Status
Status Status
// Message
Message string
// Conditions list of extracted conditions from Resource
Conditions []Condition
}
// Condition defines the general format for conditions on Kubernetes resources.
// In practice, each kubernetes resource defines their own format for conditions, but
// most (maybe all) follows this structure.
type Condition struct {
// Type condition type
Type ConditionType `json:"type,omitempty"`
// Status String that describes the condition status
Status corev1.ConditionStatus `json:"status,omitempty"`
// Reason one work CamelCase reason
Reason string `json:"reason,omitempty"`
// Message Human readable reason string
Message string `json:"message,omitempty"`
}
// Compute finds the status of a given unstructured resource. It does not
// fetch the state of the resource from a cluster, so the provided unstructured
// must have the complete state, including status.
//
// The returned result contains the status of the resource, which will be
// one of
// - InProgress
// - Current
// - Failed
// - Terminating
//
// It also contains a message that provides more information on why
// the resource has the given status. Finally, the result also contains
// a list of standard resources that would belong on the given resource.
func Compute(u *unstructured.Unstructured) (*Result, error) {
res, err := checkGenericProperties(u)
if err != nil {
return nil, err
}
// If res is not nil, it means the generic checks was able to determine
// the status of the resource. We don't need to check the type-specific
// rules.
if res != nil {
return res, nil
}
fn := GetLegacyConditionsFn(u)
if fn != nil {
return fn(u)
}
// If neither the generic properties of the resource-specific rules
// can determine status, we do one last check to see if the resource
// does expose a Ready condition. Ready conditions do not adhere
// to the Kubernetes design recommendations, but they are pretty widely
// used.
res, err = checkReadyCondition(u)
if res != nil || err != nil {
return res, err
}
// The resource is not one of the built-in types with specific
// rules and we were unable to make a decision based on the
// generic rules. In this case we assume that the absence of any known
// conditions means the resource is current.
return &Result{
Status: CurrentStatus,
Message: "Resource is current",
Conditions: []Condition{},
}, err
}
// checkReadyCondition checks if a resource has a Ready condition, and
// if so, it will use the value of this condition to determine the
// status.
// There are a few challenges with this:
// - If a resource doesn't set the Ready condition until it is True,
// the library have no way of telling whether the resource is using the
// Ready condition, so it will fall back to the strategy for unknown
// resources, which is to assume they are always reconciled.
// - If the library sees the resource before the controller has had
// a chance to update the conditions, it also will not realize the
// resource use the Ready condition.
// - There is no way to determine if a resource with the Ready condition
// set to False is making progress or is doomed.
func checkReadyCondition(u *unstructured.Unstructured) (*Result, error) {
objWithConditions, err := GetObjectWithConditions(u.Object)
if err != nil {
return nil, err
}
for _, cond := range objWithConditions.Status.Conditions {
if cond.Type != "Ready" {
continue
}
switch cond.Status {
case corev1.ConditionTrue:
return &Result{
Status: CurrentStatus,
Message: "Resource is Ready",
Conditions: []Condition{},
}, nil
case corev1.ConditionFalse:
return newInProgressStatus(cond.Reason, cond.Message), nil
case corev1.ConditionUnknown:
// For now we just treat an unknown condition value as
// InProgress. We should consider if there are better ways
// to handle it.
return newInProgressStatus(cond.Reason, cond.Message), nil
default:
// Do nothing in this case.
}
}
return nil, nil
}
// Augment takes a resource and augments the resource with the
// standard status conditions.
func Augment(u *unstructured.Unstructured) error {
res, err := Compute(u)
if err != nil {
return err
}
conditions, found, err := unstructured.NestedSlice(u.Object, "status", "conditions")
if err != nil {
return err
}
if !found {
conditions = make([]interface{}, 0)
}
currentTime := time.Now().UTC().Format(time.RFC3339)
for _, resCondition := range res.Conditions {
present := false
for _, c := range conditions {
condition, ok := c.(map[string]interface{})
if !ok {
return errors.New("condition does not have the expected structure")
}
conditionType, ok := condition["type"].(string)
if !ok {
return errors.New("condition type does not have the expected type")
}
if conditionType == string(resCondition.Type) {
conditionStatus, ok := condition["status"].(string)
if !ok {
return errors.New("condition status does not have the expected type")
}
if conditionStatus != string(resCondition.Status) {
condition["lastTransitionTime"] = currentTime
}
condition["status"] = string(resCondition.Status)
condition["lastUpdateTime"] = currentTime
condition["reason"] = resCondition.Reason
condition["message"] = resCondition.Message
present = true
}
}
if !present {
conditions = append(conditions, map[string]interface{}{
"lastTransitionTime": currentTime,
"lastUpdateTime": currentTime,
"message": resCondition.Message,
"reason": resCondition.Reason,
"status": string(resCondition.Status),
"type": string(resCondition.Type),
})
}
}
return unstructured.SetNestedSlice(u.Object, conditions, "status", "conditions")
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright 2019 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package status
import (
"strings"
corev1 "k8s.io/api/core/v1"
apiunstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
)
// newReconcilingCondition creates an reconciling condition with the given
// reason and message.
func newReconcilingCondition(reason, message string) Condition {
return Condition{
Type: ConditionReconciling,
Status: corev1.ConditionTrue,
Reason: reason,
Message: message,
}
}
func newStalledCondition(reason, message string) Condition {
return Condition{
Type: ConditionStalled,
Status: corev1.ConditionTrue,
Reason: reason,
Message: message,
}
}
// newInProgressStatus creates a status Result with the InProgress status
// and an InProgress condition.
func newInProgressStatus(reason, message string) *Result {
return &Result{
Status: InProgressStatus,
Message: message,
Conditions: []Condition{newReconcilingCondition(reason, message)},
}
}
func newFailedStatus(reason, message string) *Result {
return &Result{
Status: FailedStatus,
Message: message,
Conditions: []Condition{newStalledCondition(reason, message)},
}
}
// ObjWithConditions Represent meta object with status.condition array
type ObjWithConditions struct {
// Status as expected to be present in most compliant kubernetes resources
Status ConditionStatus `json:"status" yaml:"status"`
}
// ConditionStatus represent status with condition array
type ConditionStatus struct {
// Array of Conditions as expected to be present in kubernetes resources
Conditions []BasicCondition `json:"conditions" yaml:"conditions"`
}
// BasicCondition fields that are expected in a condition
type BasicCondition struct {
// Type Condition type
Type string `json:"type" yaml:"type"`
// Status is one of True,False,Unknown
Status corev1.ConditionStatus `json:"status" yaml:"status"`
// Reason simple single word reason in CamleCase
// +optional
Reason string `json:"reason,omitempty" yaml:"reason"`
// Message human readable reason
// +optional
Message string `json:"message,omitempty" yaml:"message"`
}
// GetObjectWithConditions return typed object
func GetObjectWithConditions(in map[string]interface{}) (*ObjWithConditions, error) {
var out = new(ObjWithConditions)
err := runtime.DefaultUnstructuredConverter.FromUnstructured(in, out)
if err != nil {
return nil, err
}
return out, nil
}
func hasConditionWithStatus(conditions []BasicCondition, conditionType string, status corev1.ConditionStatus) bool {
_, found := getConditionWithStatus(conditions, conditionType, status)
return found
}
func getConditionWithStatus(conditions []BasicCondition, conditionType string, status corev1.ConditionStatus) (BasicCondition, bool) {
for _, c := range conditions {
if c.Type == conditionType && c.Status == status {
return c, true
}
}
return BasicCondition{}, false
}
// GetStringField return field as string defaulting to value if not found
func GetStringField(obj map[string]interface{}, fieldPath string, defaultValue string) string {
var rv = defaultValue
fields := strings.Split(fieldPath, ".")
if fields[0] == "" {
fields = fields[1:]
}
val, found, err := apiunstructured.NestedFieldNoCopy(obj, fields...)
if !found || err != nil {
return rv
}
if v, ok := val.(string); ok {
return v
}
return rv
}
// GetIntField return field as string defaulting to value if not found
func GetIntField(obj map[string]interface{}, fieldPath string, defaultValue int) int {
fields := strings.Split(fieldPath, ".")
if fields[0] == "" {
fields = fields[1:]
}
val, found, err := apiunstructured.NestedFieldNoCopy(obj, fields...)
if !found || err != nil {
return defaultValue
}
switch v := val.(type) {
case int:
return v
case int32:
return int(v)
case int64:
return int(v)
}
return defaultValue
}
@@ -0,0 +1,34 @@
// Copyright 2022 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package watcher
import (
"context"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
"github.com/fluxcd/cli-utils/pkg/object"
)
// BlindStatusWatcher sees nothing.
// BlindStatusWatcher sends no update or error events.
// BlindStatusWatcher waits patiently to be cancelled.
// BlindStatusWatcher implements the StatusWatcher interface.
type BlindStatusWatcher struct{}
var _ StatusWatcher = BlindStatusWatcher{}
// Watch nothing. See no changes.
func (w BlindStatusWatcher) Watch(ctx context.Context, _ object.ObjMetadataSet, _ Options) <-chan event.Event {
doneCh := ctx.Done()
eventCh := make(chan event.Event)
go func() {
// Send SyncEvent immediately.
eventCh <- event.Event{Type: event.SyncEvent}
// Block until the context is cancelled.
<-doneCh
// Signal to the caller there will be no more events.
close(eventCh)
}()
return eventCh
}
@@ -0,0 +1,176 @@
// Copyright 2022 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package watcher
import (
"context"
"fmt"
"time"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/clusterreader"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/statusreaders"
"github.com/fluxcd/cli-utils/pkg/object"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/klog/v2"
)
// DefaultStatusWatcher reports on status updates to a set of objects.
//
// Use NewDefaultStatusWatcher to build a DefaultStatusWatcher with default settings.
type DefaultStatusWatcher struct {
// DynamicClient is used to watch of resource objects.
DynamicClient dynamic.Interface
// Mapper is used to map from GroupKind to GroupVersionKind.
Mapper meta.RESTMapper
// ResyncPeriod is how often the objects are retrieved to re-synchronize,
// in case any events were missed.
ResyncPeriod time.Duration
// StatusReader specifies a custom implementation of the
// engine.StatusReader interface that will be used to compute reconcile
// status for resource objects.
StatusReader engine.StatusReader
// ClusterReader is used to look up generated objects on-demand.
// Generated objects (ex: Deployment > ReplicaSet > Pod) are sometimes
// required for computing parent object status, to compensate for
// controllers that aren't following status conventions.
ClusterReader engine.ClusterReader
}
var _ StatusWatcher = &DefaultStatusWatcher{}
// NewDefaultStatusWatcher constructs a DynamicStatusWatcher with defaults
// chosen for general use. If you need different settings, consider building a
// DynamicStatusWatcher directly.
func NewDefaultStatusWatcher(dynamicClient dynamic.Interface, mapper meta.RESTMapper) *DefaultStatusWatcher {
return &DefaultStatusWatcher{
DynamicClient: dynamicClient,
Mapper: mapper,
ResyncPeriod: 1 * time.Hour,
StatusReader: statusreaders.NewDefaultStatusReader(mapper),
ClusterReader: &clusterreader.DynamicClusterReader{
DynamicClient: dynamicClient,
Mapper: mapper,
},
}
}
// Watch the cluster for changes made to the specified objects.
// Returns an event channel on which these updates (and errors) will be reported.
// Each update event includes the computed status of the object.
func (w *DefaultStatusWatcher) Watch(ctx context.Context, ids object.ObjMetadataSet, opts Options) <-chan event.Event {
strategy := opts.RESTScopeStrategy
if strategy == RESTScopeAutomatic {
strategy = autoSelectRESTScopeStrategy(ids)
}
var scope meta.RESTScope
var targets []GroupKindNamespace
switch strategy {
case RESTScopeRoot:
scope = meta.RESTScopeRoot
targets = rootScopeGKNs(ids)
klog.V(3).Infof("DynamicStatusWatcher starting in root-scoped mode (targets: %d)", len(targets))
case RESTScopeNamespace:
scope = meta.RESTScopeNamespace
targets = namespaceScopeGKNs(ids)
klog.V(3).Infof("DynamicStatusWatcher starting in namespace-scoped mode (targets: %d)", len(targets))
default:
return handleFatalError(fmt.Errorf("invalid RESTScopeStrategy: %v", strategy))
}
informer := &ObjectStatusReporter{
InformerFactory: NewDynamicInformerFactory(w.DynamicClient, w.ResyncPeriod),
Mapper: w.Mapper,
StatusReader: w.StatusReader,
ClusterReader: w.ClusterReader,
Targets: targets,
ObjectFilter: &AllowListObjectFilter{AllowList: ids},
RESTScope: scope,
}
return informer.Start(ctx)
}
func handleFatalError(err error) <-chan event.Event {
eventCh := make(chan event.Event)
go func() {
defer close(eventCh)
eventCh <- event.Event{
Type: event.ErrorEvent,
Error: err,
}
}()
return eventCh
}
func autoSelectRESTScopeStrategy(ids object.ObjMetadataSet) RESTScopeStrategy {
if len(uniqueNamespaces(ids)) > 1 {
return RESTScopeRoot
}
return RESTScopeNamespace
}
func rootScopeGKNs(ids object.ObjMetadataSet) []GroupKindNamespace {
gks := uniqueGKs(ids)
targets := make([]GroupKindNamespace, len(gks))
for i, gk := range gks {
targets[i] = GroupKindNamespace{
Group: gk.Group,
Kind: gk.Kind,
Namespace: "",
}
}
return targets
}
func namespaceScopeGKNs(ids object.ObjMetadataSet) []GroupKindNamespace {
return uniqueGKNs(ids)
}
// uniqueGKNs returns a set of unique GroupKindNamespaces from a set of object identifiers.
func uniqueGKNs(ids object.ObjMetadataSet) []GroupKindNamespace {
gknMap := make(map[GroupKindNamespace]struct{})
for _, id := range ids {
gkn := GroupKindNamespace{Group: id.GroupKind.Group, Kind: id.GroupKind.Kind, Namespace: id.Namespace}
gknMap[gkn] = struct{}{}
}
gknList := make([]GroupKindNamespace, 0, len(gknMap))
for gk := range gknMap {
gknList = append(gknList, gk)
}
return gknList
}
// uniqueGKs returns a set of unique GroupKinds from a set of object identifiers.
func uniqueGKs(ids object.ObjMetadataSet) []schema.GroupKind {
gkMap := make(map[schema.GroupKind]struct{})
for _, id := range ids {
gkn := schema.GroupKind{Group: id.GroupKind.Group, Kind: id.GroupKind.Kind}
gkMap[gkn] = struct{}{}
}
gkList := make([]schema.GroupKind, 0, len(gkMap))
for gk := range gkMap {
gkList = append(gkList, gk)
}
return gkList
}
func uniqueNamespaces(ids object.ObjMetadataSet) []string {
nsMap := make(map[string]struct{})
for _, id := range ids {
nsMap[id.Namespace] = struct{}{}
}
nsList := make([]string, 0, len(nsMap))
for ns := range nsMap {
nsList = append(nsList, ns)
}
return nsList
}
+39
View File
@@ -0,0 +1,39 @@
// Copyright 2022 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
// Package watcher is a library for computing the status of kubernetes resource
// objects based on watching object state from a cluster. It keeps watching
// until it is cancelled through the provided context. Updates on the status of
// objects are streamed back to the caller through a channel.
//
// # Watching Resources
//
// In order to watch a set of resources objects, create a StatusWatcher
// and pass in the list of object identifiers to the Watch function.
//
// import (
// "github.com/fluxcd/cli-utils/pkg/kstatus/watcher"
// )
//
// ids := []prune.ObjMetadata{
// {
// GroupKind: schema.GroupKind{
// Group: "apps",
// Kind: "Deployment",
// },
// Name: "dep",
// Namespace: "default",
// }
// }
//
// statusWatcher := watcher.NewDefaultStatusWatcher(dynamicClient, mapper)
// ctx, cancelFunc := context.WithCancel(context.Background())
// eventCh := statusWatcher.Watch(ctx, ids, watcher.Options{})
// for e := range eventCh {
// // Handle event
// if e.Type == event.ErrorEvent {
// cancelFunc()
// return e.Err
// }
// }
package watcher
@@ -0,0 +1,64 @@
// Copyright 2022 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package watcher
import (
"context"
"time"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/tools/cache"
)
type DynamicInformerFactory struct {
Client dynamic.Interface
ResyncPeriod time.Duration
Indexers cache.Indexers
}
func NewDynamicInformerFactory(client dynamic.Interface, resyncPeriod time.Duration) *DynamicInformerFactory {
return &DynamicInformerFactory{
Client: client,
ResyncPeriod: resyncPeriod,
Indexers: cache.Indexers{
cache.NamespaceIndex: cache.MetaNamespaceIndexFunc,
},
}
}
func (f *DynamicInformerFactory) NewInformer(ctx context.Context, mapping *meta.RESTMapping, namespace string) cache.SharedIndexInformer {
// Unstructured example output need `"apiVersion"` and `"kind"` set.
example := &unstructured.Unstructured{}
example.SetGroupVersionKind(mapping.GroupVersionKind)
lw := &cache.ListWatch{
ListFunc: func(options metav1.ListOptions) (runtime.Object, error) {
return f.Client.Resource(mapping.Resource).
Namespace(namespace).
List(ctx, options)
},
WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) {
return f.Client.Resource(mapping.Resource).
Namespace(namespace).
Watch(ctx, options)
},
}
// Wrap the ListWatch with the client to allow the reflector to detect
// if the client supports WatchList semantics. This is important for
// fake clients used in tests, which do not support WatchList.
wrappedLW := cache.ToListWatcherWithWatchListSemantics(lw, f.Client)
return cache.NewSharedIndexInformer(
wrappedLW,
example,
f.ResyncPeriod,
f.Indexers,
)
}
+122
View File
@@ -0,0 +1,122 @@
// Copyright 2022 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package watcher
import (
"context"
"fmt"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
"k8s.io/klog/v2"
)
// eventFunnel wraps a list of event channels and multiplexes them down to a
// single event channel. New input channels can be added at runtime, and the
// output channel will remain open until all input channels are closed.
type eventFunnel struct {
// ctx closure triggers shutdown
ctx context.Context
// outCh is the funnel that consumes all events from input channels
outCh chan event.Event
// doneCh is closed after outCh is closed.
// This allows blocking until done without consuming events.
doneCh chan struct{}
// counterCh is used to track the number of open input channels.
counterCh chan int
}
func newEventFunnel(ctx context.Context) *eventFunnel {
funnel := &eventFunnel{
ctx: ctx,
outCh: make(chan event.Event),
doneCh: make(chan struct{}),
counterCh: make(chan int),
}
// Wait until the context is done and all input channels are closed.
// Then close out and done channels to signal completion.
go func() {
defer func() {
// Don't close counterCh, otherwise AddInputChannel may panic.
klog.V(5).Info("Closing funnel")
close(funnel.outCh)
close(funnel.doneCh)
}()
ctxDoneCh := ctx.Done()
// Count input channels that have been added and not closed.
inputs := 0
for {
select {
case delta := <-funnel.counterCh:
inputs += delta
klog.V(5).Infof("Funnel input channels (%+d): %d", delta, inputs)
case <-ctxDoneCh:
// Stop waiting for context closure.
// Nil channel avoids busy waiting.
ctxDoneCh = nil
}
if ctxDoneCh == nil && inputs <= 0 {
// Context is closed and all input channels are closed.
break
}
}
}()
return funnel
}
// Add a new input channel to the multiplexer.
func (m *eventFunnel) AddInputChannel(inCh <-chan event.Event) error {
select {
case <-m.ctx.Done(): // skip, if context is closed
return &EventFunnelClosedError{ContextError: m.ctx.Err()}
case m.counterCh <- 1: // increment counter
}
// Create a multiplexer for each new event channel.
go m.drain(inCh, m.outCh)
return nil
}
// OutputChannel channel receives all events sent to input channels.
// This channel is closed after all input channels are closed.
func (m *eventFunnel) OutputChannel() <-chan event.Event {
return m.outCh
}
// Done channel is closed after the Output channel is closed.
// This allows blocking until done without consuming events.
// If no input channels have been added yet, the done channel will be nil.
func (m *eventFunnel) Done() <-chan struct{} {
return m.doneCh
}
// drain a single input channel to a single output channel.
func (m *eventFunnel) drain(inCh <-chan event.Event, outCh chan<- event.Event) {
defer func() {
m.counterCh <- -1 // decrement counter
}()
for event := range inCh {
outCh <- event
}
}
type EventFunnelClosedError struct {
ContextError error
}
func (e *EventFunnelClosedError) Error() string {
return fmt.Sprintf("event funnel closed: %v", e.ContextError)
}
func (e *EventFunnelClosedError) Is(err error) bool {
fcErr, ok := err.(*EventFunnelClosedError)
if !ok {
return false
}
return e.ContextError == fcErr.ContextError
}
func (e *EventFunnelClosedError) Unwrap() error {
return e.ContextError
}
@@ -0,0 +1,30 @@
// Copyright 2022 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package watcher
import (
"github.com/fluxcd/cli-utils/pkg/object"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// ObjectFilter allows for filtering objects.
type ObjectFilter interface {
// Filter returns true if the object should be skipped.
Filter(obj *unstructured.Unstructured) bool
}
// AllowListObjectFilter filters objects not in the allow list.
// AllowListObjectFilter implements ObjectFilter.
type AllowListObjectFilter struct {
AllowList object.ObjMetadataSet
}
var _ ObjectFilter = &AllowListObjectFilter{}
// Filter returns true if the object should be skipped, because it is NOT in the
// AllowList.
func (f *AllowListObjectFilter) Filter(obj *unstructured.Unstructured) bool {
id := object.UnstructuredToObjMetadata(obj)
return !f.AllowList.Contains(id)
}
@@ -0,0 +1,895 @@
// Copyright 2022 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package watcher
import (
"context"
"errors"
"fmt"
"io"
"sync"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/client-go/tools/cache"
"k8s.io/klog/v2"
"k8s.io/utils/clock"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
"github.com/fluxcd/cli-utils/pkg/object"
)
// GroupKindNamespace identifies an informer target.
// When used as an informer target, the namespace is optional.
// When the namespace is empty for namespaced resources, all namespaces are watched.
type GroupKindNamespace struct {
Group string
Kind string
Namespace string
}
// String returns a serialized form suitable for logging.
func (gkn GroupKindNamespace) String() string {
return fmt.Sprintf("%s/%s/namespaces/%s",
gkn.Group, gkn.Kind, gkn.Namespace)
}
func (gkn GroupKindNamespace) GroupKind() schema.GroupKind {
return schema.GroupKind{Group: gkn.Group, Kind: gkn.Kind}
}
// ObjectStatusReporter reports on updates to objects (instances) using a
// network of informers to watch one or more resources (types).
//
// Unlike SharedIndexInformer, ObjectStatusReporter...
// - Reports object status.
// - Can watch multiple resource types simultaneously.
// - Specific objects can be ignored for efficiency by specifying an ObjectFilter.
// - Resolves GroupKinds into Resources at runtime, to pick up newly added
// resources.
// - Starts and Stops individual watches automaically to reduce errors when a
// CRD or Namespace is deleted.
// - Resources can be watched in root-scope mode or namespace-scope mode,
// allowing the caller to optimize for efficiency or least-privilege.
// - Gives unschedulable Pods (and objects that generate them) a 15s grace
// period before reporting them as Failed.
// - Resets the RESTMapper cache automatically when CRDs are modified.
//
// ObjectStatusReporter is NOT repeatable. It will panic if started more than
// once. If you need a repeatable factory, use DefaultStatusWatcher.
//
// TODO: support detection of added/removed api extensions at runtime
// TODO: Watch CRDs & Namespaces, even if not in the set of IDs.
// TODO: Retry with backoff if in namespace-scoped mode, to allow CRDs & namespaces to be created asynchronously
type ObjectStatusReporter struct {
// InformerFactory is used to build informers
InformerFactory *DynamicInformerFactory
// Mapper is used to map from GroupKind to GroupVersionKind.
Mapper meta.RESTMapper
// StatusReader specifies a custom implementation of the
// engine.StatusReader interface that will be used to compute reconcile
// status for resource objects.
StatusReader engine.StatusReader
// ClusterReader is used to look up generated objects on-demand.
// Generated objects (ex: Deployment > ReplicaSet > Pod) are sometimes
// required for computing parent object status, to compensate for
// controllers that aren't following status conventions.
ClusterReader engine.ClusterReader
// GroupKinds is the list of GroupKinds to watch.
Targets []GroupKindNamespace
// ObjectFilter is used to decide which objects to ingore.
ObjectFilter ObjectFilter
// RESTScope specifies whether to ListAndWatch resources at the namespace
// or cluster (root) level. Using root scope is more efficient, but
// namespace scope may require fewer permissions.
RESTScope meta.RESTScope
// lock guards modification of the subsequent stateful fields
lock sync.Mutex
// gk2gkn maps GKs to GKNs to make it easy/cheap to look up.
gk2gkn map[schema.GroupKind]map[GroupKindNamespace]struct{}
// ns2gkn maps Namespaces to GKNs to make it easy/cheap to look up.
ns2gkn map[string]map[GroupKindNamespace]struct{}
// informerRefs tracks which informers have been started and stopped
informerRefs map[GroupKindNamespace]*informerReference
// context will be cancelled when the reporter should stop.
context context.Context
// cancel function that stops the context.
// This should only be called after the terminal error event has been sent.
cancel context.CancelFunc
// funnel multiplexes multiple input channels into one output channel,
// allowing input channels to be added and removed at runtime.
funnel *eventFunnel
// taskManager makes it possible to cancel scheduled tasks.
taskManager *taskManager
started bool
stopped bool
}
func (w *ObjectStatusReporter) Start(ctx context.Context) <-chan event.Event {
w.lock.Lock()
defer w.lock.Unlock()
if w.started {
panic("ObjectStatusInformer cannot be restarted")
}
w.taskManager = &taskManager{}
// Map GroupKinds to sets of GroupKindNamespaces for fast lookups.
// This is the only time we modify the map.
// So it should be safe to read from multiple threads after this.
w.gk2gkn = make(map[schema.GroupKind]map[GroupKindNamespace]struct{})
for _, gkn := range w.Targets {
gk := gkn.GroupKind()
m, found := w.gk2gkn[gk]
if !found {
m = make(map[GroupKindNamespace]struct{})
w.gk2gkn[gk] = m
}
m[gkn] = struct{}{}
}
// Map namespaces to sets of GroupKindNamespaces for fast lookups.
// This is the only time we modify the map.
// So it should be safe to read from multiple threads after this.
w.ns2gkn = make(map[string]map[GroupKindNamespace]struct{})
for _, gkn := range w.Targets {
ns := gkn.Namespace
m, found := w.ns2gkn[ns]
if !found {
m = make(map[GroupKindNamespace]struct{})
w.ns2gkn[ns] = m
}
m[gkn] = struct{}{}
}
// Initialize the informer map with references to track their start/stop.
// This is the only time we modify the map.
// So it should be safe to read from multiple threads after this.
w.informerRefs = make(map[GroupKindNamespace]*informerReference, len(w.Targets))
for _, gkn := range w.Targets {
w.informerRefs[gkn] = &informerReference{}
}
ctx, cancel := context.WithCancel(ctx)
w.context = ctx
w.cancel = cancel
// Use an event funnel to multiplex events through multiple input channels
// into out output channel. We can't use the normal fan-in pattern, because
// we need to be able to add and remove new input channels at runtime, as
// new informers are created and destroyed.
w.funnel = newEventFunnel(ctx)
// Send start requests.
for _, gkn := range w.Targets {
w.startInformer(gkn)
}
w.started = true
// Block until the event funnel is closed.
// The event funnel will close after all the informer channels are closed.
// The informer channels will close after the informers have stopped.
// The informers will stop after their context is cancelled.
go func() {
<-w.funnel.Done()
w.lock.Lock()
defer w.lock.Unlock()
w.stopped = true
}()
// Wait until all informers are synced or stopped, then send a SyncEvent.
syncEventCh := make(chan event.Event)
err := w.funnel.AddInputChannel(syncEventCh)
if err != nil {
// Reporter already stopped.
return handleFatalError(fmt.Errorf("reporter failed to start: %v", err))
}
go func() {
defer close(syncEventCh)
// TODO: should we use something less aggressive, like wait.BackoffUntil?
if cache.WaitForCacheSync(ctx.Done(), w.HasSynced) {
syncEventCh <- event.Event{
Type: event.SyncEvent,
}
}
}()
return w.funnel.OutputChannel()
}
// Stop triggers the cancellation of the reporter context, and closure of the
// event channel without sending an error event.
func (w *ObjectStatusReporter) Stop() {
klog.V(4).Info("Stopping reporter")
w.cancel()
}
// HasSynced returns true if all the started informers have been synced.
//
// Use the following to block waiting for synchronization:
// synced := cache.WaitForCacheSync(stopCh, informer.HasSynced)
func (w *ObjectStatusReporter) HasSynced() bool {
w.lock.Lock()
defer w.lock.Unlock()
if w.stopped || !w.started {
return false
}
pending := make([]GroupKindNamespace, 0, len(w.informerRefs))
for gke, informer := range w.informerRefs {
if informer.HasStarted() && !informer.HasSynced() {
pending = append(pending, gke)
}
}
if len(pending) > 0 {
klog.V(5).Infof("Informers pending synchronization: %v", pending)
return false
}
return true
}
// startInformer adds the specified GroupKindNamespace to the start channel to
// be started asynchronously.
func (w *ObjectStatusReporter) startInformer(gkn GroupKindNamespace) {
ctx, ok := w.informerRefs[gkn].Start(w.context)
if !ok {
klog.V(5).Infof("Watch start skipped (already started): %v", gkn)
// already started
return
}
go w.startInformerWithRetry(ctx, gkn)
}
// stopInformer stops the informer watching the specified GroupKindNamespace.
func (w *ObjectStatusReporter) stopInformer(gkn GroupKindNamespace) {
w.informerRefs[gkn].Stop()
}
func (w *ObjectStatusReporter) startInformerWithRetry(ctx context.Context, gkn GroupKindNamespace) {
realClock := &clock.RealClock{}
// TODO nolint can be removed once https://github.com/kubernetes/kubernetes/issues/118638 is resolved
backoffManager := wait.NewExponentialBackoffManager(800*time.Millisecond, 30*time.Second, 2*time.Minute, 2.0, 1.0, realClock) //nolint:staticcheck
retryCtx, retryCancel := context.WithCancel(ctx)
wait.BackoffUntil(func() {
err := w.startInformerNow(
ctx,
gkn,
)
if err != nil {
if meta.IsNoMatchError(err) {
// CRD (or api extension) not installed
// TODO: retry if CRDs are not being watched
klog.V(3).Infof("Watch start error (blocking until CRD is added): %v: %v", gkn, err)
// Cancel the parent context, which will stop the retries too.
w.stopInformer(gkn)
return
}
// Create a temporary input channel to send the error event.
eventCh := make(chan event.Event)
defer close(eventCh)
err := w.funnel.AddInputChannel(eventCh)
if err != nil {
// Reporter already stopped.
// This is fine. 🔥
klog.V(5).Infof("Informer failed to start: %v", err)
return
}
// Send error event and stop the reporter!
w.handleFatalError(eventCh, err)
return
}
// Success! - Stop retrying
retryCancel()
}, backoffManager, true, retryCtx.Done())
}
// startInformerNow starts an informer to watch for changes to a
// GroupKindNamespace. Changes are filtered and passed by event channel into the
// funnel. Each update event includes the computed status of the object.
// An error is returned if the informer could not be created.
func (w *ObjectStatusReporter) startInformerNow(
ctx context.Context,
gkn GroupKindNamespace,
) error {
// Look up the mapping for this GroupKind.
// If it doesn't exist, either delay watching or emit an error.
gk := gkn.GroupKind()
mapping, err := w.Mapper.RESTMapping(gk)
if err != nil {
// Might be a NoResourceMatchError/NoKindMatchError
return err
}
informer := w.InformerFactory.NewInformer(ctx, mapping, gkn.Namespace)
w.informerRefs[gkn].SetInformer(informer)
eventCh := make(chan event.Event)
// Add this event channel to the output multiplexer
err = w.funnel.AddInputChannel(eventCh)
if err != nil {
// Reporter already stopped.
return fmt.Errorf("informer failed to build event handler: %w", err)
}
// Handler called when ListAndWatch errors.
// Custom handler stops the informer if the resource is NotFound (CRD deleted).
err = informer.SetWatchErrorHandler(func(r *cache.Reflector, err error) {
w.watchErrorHandler(gkn, eventCh, err)
})
if err != nil {
// Should never happen.
// Informer can't have started yet. We just created it.
return fmt.Errorf("failed to set error handler on new informer for %v: %v", mapping.Resource, err)
}
_, err = informer.AddEventHandler(w.eventHandler(ctx, eventCh))
if err != nil {
// Should never happen.
return fmt.Errorf("failed add event handler on new informer for %v: %v", mapping.Resource, err)
}
// Start the informer in the background.
// Informer will be stopped when the context is cancelled.
go func() {
klog.V(3).Infof("Watch starting: %v", gkn)
informer.Run(ctx.Done())
klog.V(3).Infof("Watch stopped: %v", gkn)
// Signal to the caller there will be no more events for this GroupKind.
close(eventCh)
}()
return nil
}
func (w *ObjectStatusReporter) forEachTargetWithGroupKind(gk schema.GroupKind, fn func(GroupKindNamespace)) {
for gkn := range w.gk2gkn[gk] {
fn(gkn)
}
}
func (w *ObjectStatusReporter) forEachTargetWithNamespace(ns string, fn func(GroupKindNamespace)) {
for gkn := range w.ns2gkn[ns] {
fn(gkn)
}
}
// readStatusFromObject is a convenience function to read object status with a
// StatusReader using a ClusterReader to retrieve generated objects.
func (w *ObjectStatusReporter) readStatusFromObject(
ctx context.Context,
obj *unstructured.Unstructured,
) (*event.ResourceStatus, error) {
return w.StatusReader.ReadStatusForObject(ctx, w.ClusterReader, obj)
}
// readStatusFromCluster is a convenience function to read object status with a
// StatusReader using a ClusterReader to retrieve the object and its generated
// objects.
func (w *ObjectStatusReporter) readStatusFromCluster(
ctx context.Context,
id object.ObjMetadata,
) (*event.ResourceStatus, error) {
return w.StatusReader.ReadStatus(ctx, w.ClusterReader, id)
}
// deletedStatus builds a ResourceStatus for a deleted object.
//
// StatusReader.ReadStatusForObject doesn't handle nil objects as input. So
// this builds the status manually.
// TODO: find a way to delegate this back to the status package.
func deletedStatus(id object.ObjMetadata) *event.ResourceStatus {
// Status is always NotFound after deltion.
// Passed obj represents the last known state, not the current state.
result := &event.ResourceStatus{
Identifier: id,
Status: status.NotFoundStatus,
Message: "Resource not found",
}
return &event.ResourceStatus{
Identifier: id,
Resource: nil, // deleted object has no
Status: result.Status,
Message: result.Message,
// If deleted with foreground deletion, a finalizer will have blocked
// deletion until all the generated resources are deleted.
// TODO: Handle lookup of generated resources when not using foreground deletion.
GeneratedResources: nil,
}
}
// eventHandler builds an event handler to compute object status.
// Returns an event channel on which these stats updates will be reported.
func (w *ObjectStatusReporter) eventHandler(
ctx context.Context,
eventCh chan<- event.Event,
) cache.ResourceEventHandler {
var handler cache.ResourceEventHandlerFuncs
handler.AddFunc = func(iobj interface{}) {
// Bail early if the context is cancelled, to avoid unnecessary work.
if ctx.Err() != nil {
return
}
obj, ok := iobj.(*unstructured.Unstructured)
if !ok {
panic(fmt.Sprintf("AddFunc received unexpected object type %T", iobj))
}
id := object.UnstructuredToObjMetadata(obj)
if w.ObjectFilter.Filter(obj) {
klog.V(7).Infof("Watch Event Skipped: AddFunc: %s", id)
return
}
klog.V(5).Infof("AddFunc: Computing status for object: %s", id)
// cancel any scheduled status update for this object
w.taskManager.Cancel(id)
rs, err := w.readStatusFromObject(ctx, obj)
if err != nil {
// Send error event and stop the reporter!
w.handleFatalError(eventCh, fmt.Errorf("failed to compute object status: %s: %w", id, err))
return
}
if object.IsNamespace(obj) {
klog.V(5).Infof("AddFunc: Namespace added: %v", id)
w.onNamespaceAdd(obj)
} else if object.IsCRD(obj) {
klog.V(5).Infof("AddFunc: CRD added: %v", id)
w.onCRDAdd(obj)
}
if isObjectUnschedulable(rs) {
klog.V(5).Infof("AddFunc: object unschedulable: %v", id)
// schedule delayed status update
w.taskManager.Schedule(ctx, id, status.ScheduleWindow,
w.newStatusCheckTaskFunc(ctx, eventCh, id))
}
klog.V(7).Infof("AddFunc: sending update event: %v", rs)
eventCh <- event.Event{
Type: event.ResourceUpdateEvent,
Resource: rs,
}
}
handler.UpdateFunc = func(_, iobj interface{}) {
// Bail early if the context is cancelled, to avoid unnecessary work.
if ctx.Err() != nil {
return
}
obj, ok := iobj.(*unstructured.Unstructured)
if !ok {
panic(fmt.Sprintf("UpdateFunc received unexpected object type %T", iobj))
}
id := object.UnstructuredToObjMetadata(obj)
if w.ObjectFilter.Filter(obj) {
klog.V(7).Infof("UpdateFunc: Watch Event Skipped: %s", id)
return
}
klog.V(5).Infof("UpdateFunc: Computing status for object: %s", id)
// cancel any scheduled status update for this object
w.taskManager.Cancel(id)
rs, err := w.readStatusFromObject(ctx, obj)
if err != nil {
// Send error event and stop the reporter!
w.handleFatalError(eventCh, fmt.Errorf("failed to compute object status: %s: %w", id, err))
return
}
if object.IsNamespace(obj) {
klog.V(5).Infof("UpdateFunc: Namespace updated: %v", id)
w.onNamespaceUpdate(obj)
} else if object.IsCRD(obj) {
klog.V(5).Infof("UpdateFunc: CRD updated: %v", id)
w.onCRDUpdate(obj)
}
if isObjectUnschedulable(rs) {
klog.V(5).Infof("UpdateFunc: object unschedulable: %v", id)
// schedule delayed status update
w.taskManager.Schedule(ctx, id, status.ScheduleWindow,
w.newStatusCheckTaskFunc(ctx, eventCh, id))
}
klog.V(7).Infof("UpdateFunc: sending update event: %v", rs)
eventCh <- event.Event{
Type: event.ResourceUpdateEvent,
Resource: rs,
}
}
handler.DeleteFunc = func(iobj interface{}) {
// Bail early if the context is cancelled, to avoid unnecessary work.
if ctx.Err() != nil {
return
}
if tombstone, ok := iobj.(cache.DeletedFinalStateUnknown); ok {
// Last state unknown. Possibly stale.
// TODO: Should we propegate this uncertainty to the caller?
iobj = tombstone.Obj
}
obj, ok := iobj.(*unstructured.Unstructured)
if !ok {
panic(fmt.Sprintf("DeleteFunc received unexpected object type %T", iobj))
}
id := object.UnstructuredToObjMetadata(obj)
if w.ObjectFilter.Filter(obj) {
klog.V(7).Infof("DeleteFunc: Watch Event Skipped: %s", id)
return
}
klog.V(5).Infof("DeleteFunc: Computing status for object: %s", id)
// cancel any scheduled status update for this object
w.taskManager.Cancel(id)
if object.IsNamespace(obj) {
klog.V(5).Infof("DeleteFunc: Namespace deleted: %v", id)
w.onNamespaceDelete(obj)
} else if object.IsCRD(obj) {
klog.V(5).Infof("DeleteFunc: CRD deleted: %v", id)
w.onCRDDelete(obj)
}
rs := deletedStatus(id)
klog.V(7).Infof("DeleteFunc: sending update event: %v", rs)
eventCh <- event.Event{
Type: event.ResourceUpdateEvent,
Resource: rs,
}
}
return handler
}
// onCRDAdd handles creating a new informer to watch the new resource type.
func (w *ObjectStatusReporter) onCRDAdd(obj *unstructured.Unstructured) {
gk, found := object.GetCRDGroupKind(obj)
if !found {
id := object.UnstructuredToObjMetadata(obj)
klog.Warningf("Invalid CRD added: missing group and/or kind: %v", id)
// Don't return an error, because this should not inturrupt the task queue.
// TODO: Allow non-fatal errors to be reported using a specific error type.
return
}
klog.V(3).Infof("CRD added for %s", gk)
klog.V(3).Info("Resetting RESTMapper")
// Reset mapper to invalidate cache.
meta.MaybeResetRESTMapper(w.Mapper)
w.forEachTargetWithGroupKind(gk, func(gkn GroupKindNamespace) {
w.startInformer(gkn)
})
}
// onCRDUpdate handles creating a new informer to watch the updated resource type.
func (w *ObjectStatusReporter) onCRDUpdate(newObj *unstructured.Unstructured) {
gk, found := object.GetCRDGroupKind(newObj)
if !found {
id := object.UnstructuredToObjMetadata(newObj)
klog.Warningf("Invalid CRD updated: missing group and/or kind: %v", id)
// Don't return an error, because this should not inturrupt the task queue.
// TODO: Allow non-fatal errors to be reported using a specific error type.
return
}
klog.V(3).Infof("CRD updated for %s", gk)
klog.V(3).Info("Resetting RESTMapper")
// Reset mapper to invalidate cache.
meta.MaybeResetRESTMapper(w.Mapper)
w.forEachTargetWithGroupKind(gk, func(gkn GroupKindNamespace) {
w.startInformer(gkn)
})
}
// onCRDDelete handles stopping the informer watching the deleted resource type.
func (w *ObjectStatusReporter) onCRDDelete(oldObj *unstructured.Unstructured) {
gk, found := object.GetCRDGroupKind(oldObj)
if !found {
id := object.UnstructuredToObjMetadata(oldObj)
klog.Warningf("Invalid CRD deleted: missing group and/or kind: %v", id)
// Don't return an error, because this should not inturrupt the task queue.
// TODO: Allow non-fatal errors to be reported using a specific error type.
return
}
klog.V(3).Infof("CRD deleted for %s", gk)
w.forEachTargetWithGroupKind(gk, func(gkn GroupKindNamespace) {
w.stopInformer(gkn)
})
klog.V(3).Info("Resetting RESTMapper")
// Reset mapper to invalidate cache.
meta.MaybeResetRESTMapper(w.Mapper)
}
// onNamespaceAdd handles creating new informers to watch this namespace.
func (w *ObjectStatusReporter) onNamespaceAdd(obj *unstructured.Unstructured) {
if w.RESTScope == meta.RESTScopeRoot {
// When watching resources across all namespaces,
// we don't need to start or stop any
// namespace-specific informers.
return
}
namespace := obj.GetName()
w.forEachTargetWithNamespace(namespace, func(gkn GroupKindNamespace) {
w.startInformer(gkn)
})
}
// onNamespaceUpdate handles creating new informers to watch this namespace.
func (w *ObjectStatusReporter) onNamespaceUpdate(obj *unstructured.Unstructured) {
if w.RESTScope == meta.RESTScopeRoot {
// When watching resources across all namespaces,
// we don't need to start or stop any
// namespace-specific informers.
return
}
namespace := obj.GetName()
w.forEachTargetWithNamespace(namespace, func(gkn GroupKindNamespace) {
w.startInformer(gkn)
})
}
// onNamespaceDelete handles stopping informers watching this namespace.
func (w *ObjectStatusReporter) onNamespaceDelete(obj *unstructured.Unstructured) {
if w.RESTScope == meta.RESTScopeRoot {
// When watching resources across all namespaces,
// we don't need to start or stop any
// namespace-specific informers.
return
}
namespace := obj.GetName()
w.forEachTargetWithNamespace(namespace, func(gkn GroupKindNamespace) {
w.stopInformer(gkn)
})
}
// newStatusCheckTaskFunc returns a taskFund that reads the status of an object
// from the cluster and sends it over the event channel.
//
// This method should only be used for generated resource objects, as it's much
// slower at scale than watching the resource for updates.
func (w *ObjectStatusReporter) newStatusCheckTaskFunc(
ctx context.Context,
eventCh chan<- event.Event,
id object.ObjMetadata,
) taskFunc {
return func() {
klog.V(5).Infof("Re-reading object status: %s", id)
// check again
rs, err := w.readStatusFromCluster(ctx, id)
if err != nil {
// Send error event and stop the reporter!
// TODO: retry N times before terminating
w.handleFatalError(eventCh, err)
return
}
eventCh <- event.Event{
Type: event.ResourceUpdateEvent,
Resource: rs,
}
}
}
func (w *ObjectStatusReporter) handleFatalError(eventCh chan<- event.Event, err error) {
klog.V(5).Infof("Reporter error: %v", err)
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return
}
eventCh <- event.Event{
Type: event.ErrorEvent,
Error: err,
}
w.Stop()
}
// watchErrorHandler logs errors and cancels the informer for this GroupKind
// if the NotFound error is received, which usually means the CRD was deleted.
// Based on DefaultWatchErrorHandler from k8s.io/client-go@v0.23.2/tools/cache/reflector.go
func (w *ObjectStatusReporter) watchErrorHandler(gkn GroupKindNamespace, eventCh chan<- event.Event, err error) {
switch {
// Stop channel closed
case err == io.EOF:
klog.V(5).Infof("ListAndWatch error (termination expected): %v: %v", gkn, err)
// Watch connection closed
case err == io.ErrUnexpectedEOF:
klog.V(1).Infof("ListAndWatch error (retry expected): %v: %v", gkn, err)
// Context done
case errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded):
klog.V(5).Infof("ListAndWatch error (termination expected): %v: %v", gkn, err)
// resourceVersion too old
case apierrors.IsResourceExpired(err):
// Keep retrying
klog.V(5).Infof("ListAndWatch error (retry expected): %v: %v", gkn, err)
// Resource unregistered (DEPRECATED, see NotFound)
case apierrors.IsGone(err):
klog.V(5).Infof("ListAndWatch error (retry expected): %v: %v", gkn, err)
// Resource not registered
case apierrors.IsNotFound(err):
klog.V(3).Infof("ListAndWatch error (termination expected): %v: stopping all informers for this GroupKind: %v", gkn, err)
w.forEachTargetWithGroupKind(gkn.GroupKind(), func(gkn GroupKindNamespace) {
w.stopInformer(gkn)
})
// Insufficient permissions
case apierrors.IsForbidden(err):
klog.V(3).Infof("ListAndWatch error (termination expected): %v: stopping all informers: %v", gkn, err)
w.handleFatalError(eventCh, err)
// Unexpected error
default:
klog.Warningf("ListAndWatch error (retry expected): %v: %v", gkn, err)
}
}
// informerReference tracks informer lifecycle.
type informerReference struct {
// lock guards the subsequent stateful fields
lock sync.Mutex
informer cache.SharedIndexInformer
context context.Context
cancel context.CancelFunc
started bool
}
// Start returns a wrapped context that can be cancelled.
// Returns nil & false if already started.
func (ir *informerReference) Start(ctx context.Context) (context.Context, bool) {
ir.lock.Lock()
defer ir.lock.Unlock()
if ir.started {
return nil, false
}
ctx, cancel := context.WithCancel(ctx)
ir.context = ctx
ir.cancel = cancel
ir.started = true
return ctx, true
}
func (ir *informerReference) SetInformer(informer cache.SharedIndexInformer) {
ir.lock.Lock()
defer ir.lock.Unlock()
ir.informer = informer
}
func (ir *informerReference) HasSynced() bool {
ir.lock.Lock()
defer ir.lock.Unlock()
if !ir.started {
return false
}
if ir.informer == nil {
return false
}
return ir.informer.HasSynced()
}
func (ir *informerReference) HasStarted() bool {
ir.lock.Lock()
defer ir.lock.Unlock()
return ir.started
}
// Stop cancels the context, if it's been started.
func (ir *informerReference) Stop() {
ir.lock.Lock()
defer ir.lock.Unlock()
if !ir.started {
return
}
ir.cancel()
ir.started = false
ir.context = nil
}
type taskFunc func()
// taskManager manages a set of tasks with object identifiers.
// This makes starting and stopping the tasks thread-safe.
type taskManager struct {
lock sync.Mutex
cancelFuncs map[object.ObjMetadata]context.CancelFunc
}
func (tm *taskManager) Schedule(parentCtx context.Context, id object.ObjMetadata, delay time.Duration, task taskFunc) {
tm.lock.Lock()
defer tm.lock.Unlock()
if tm.cancelFuncs == nil {
tm.cancelFuncs = make(map[object.ObjMetadata]context.CancelFunc)
}
cancel, found := tm.cancelFuncs[id]
if found {
// Cancel the existing scheduled task and replace it.
cancel()
}
taskCtx, cancel := context.WithTimeout(context.Background(), delay)
tm.cancelFuncs[id] = cancel
go func() {
klog.V(5).Infof("Task scheduled (%v) for object (%s)", delay, id)
select {
case <-parentCtx.Done():
// stop waiting
cancel()
case <-taskCtx.Done():
if taskCtx.Err() == context.DeadlineExceeded {
klog.V(5).Infof("Task executing (after %v) for object (%v)", delay, id)
task()
}
// else stop waiting
}
}()
}
func (tm *taskManager) Cancel(id object.ObjMetadata) {
tm.lock.Lock()
defer tm.lock.Unlock()
cancelFunc, found := tm.cancelFuncs[id]
if !found {
// already cancelled or not added
return
}
delete(tm.cancelFuncs, id)
cancelFunc()
if len(tm.cancelFuncs) == 0 {
tm.cancelFuncs = nil
}
}
@@ -0,0 +1,25 @@
// Code generated by "stringer -type=RESTScopeStrategy -linecomment"; DO NOT EDIT.
package watcher
import "strconv"
func _() {
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
var x [1]struct{}
_ = x[RESTScopeAutomatic-0]
_ = x[RESTScopeRoot-1]
_ = x[RESTScopeNamespace-2]
}
const _RESTScopeStrategy_name = "automaticrootnamespace"
var _RESTScopeStrategy_index = [...]uint8{0, 9, 13, 22}
func (i RESTScopeStrategy) String() string {
if i < 0 || i >= RESTScopeStrategy(len(_RESTScopeStrategy_index)-1) {
return "RESTScopeStrategy(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _RESTScopeStrategy_name[_RESTScopeStrategy_index[i]:_RESTScopeStrategy_index[i+1]]
}
@@ -0,0 +1,69 @@
// Copyright 2022 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package watcher
import (
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
"github.com/fluxcd/cli-utils/pkg/object"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
)
// isObjectUnschedulable returns true if the object or any of its generated resources
// is an unschedulable pod.
//
// This status is computed recursively, so it can handle objects that generate
// objects that generate pods, as long as the input ResourceStatus has those
// GeneratedResources computed.
func isObjectUnschedulable(rs *event.ResourceStatus) bool {
if rs.Error != nil {
return false
}
if rs.Status != status.InProgressStatus {
return false
}
if isPodUnschedulable(rs.Resource) {
return true
}
// recurse through generated resources
for _, subRS := range rs.GeneratedResources {
if isObjectUnschedulable(subRS) {
return true
}
}
return false
}
// isPodUnschedulable returns true if the object is a pod and is unschedulable
// according to a False PodScheduled condition.
func isPodUnschedulable(obj *unstructured.Unstructured) bool {
if obj == nil {
return false
}
gk := obj.GroupVersionKind().GroupKind()
if gk != (schema.GroupKind{Kind: "Pod"}) {
return false
}
icnds, found, err := object.NestedField(obj.Object, "status", "conditions")
if err != nil || !found {
return false
}
cnds, ok := icnds.([]interface{})
if !ok {
return false
}
for _, icnd := range cnds {
cnd, ok := icnd.(map[string]interface{})
if !ok {
return false
}
if cnd["type"] == "PodScheduled" &&
cnd["status"] == "False" &&
cnd["reason"] == "Unschedulable" {
return true
}
}
return false
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2022 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package watcher
import (
"context"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
"github.com/fluxcd/cli-utils/pkg/object"
)
// StatusWatcher watches a set of objects for status updates.
type StatusWatcher interface {
// Watch a set of objects for status updates.
// Watching should stop if the context is cancelled.
// Events should only be sent for the specified objects.
// The event channel should be closed when the watching stops.
Watch(context.Context, object.ObjMetadataSet, Options) <-chan event.Event
}
// Options can be provided when creating a new StatusWatcher to customize the
// behavior.
type Options struct {
// RESTScopeStrategy specifies which strategy to use when listing and
// watching resources. By default, the strategy is selected automatically.
RESTScopeStrategy RESTScopeStrategy
}
//go:generate stringer -type=RESTScopeStrategy -linecomment
type RESTScopeStrategy int
const (
RESTScopeAutomatic RESTScopeStrategy = iota // automatic
RESTScopeRoot // root
RESTScopeNamespace // namespace
)
+24
View File
@@ -0,0 +1,24 @@
// Copyright 2022 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package object
import (
"fmt"
)
// InvalidAnnotationError represents an invalid annotation.
// Fields are exposed to allow callers to perform introspection.
type InvalidAnnotationError struct {
Annotation string
Cause error
}
func (iae InvalidAnnotationError) Error() string {
return fmt.Sprintf("invalid %q annotation: %v",
iae.Annotation, iae.Cause)
}
func (iae InvalidAnnotationError) Unwrap() error {
return iae.Cause
}
+114
View File
@@ -0,0 +1,114 @@
// Copyright 2021 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package object
import (
"fmt"
"regexp"
"strings"
"k8s.io/apimachinery/pkg/util/validation/field"
)
// NestedField gets a value from a KRM map, if it exists, otherwise nil.
// Fields can be string (map key) or int (array index).
func NestedField(obj map[string]interface{}, fields ...interface{}) (interface{}, bool, error) {
var val interface{} = obj
for i, field := range fields {
if val == nil {
return nil, false, nil
}
switch typedField := field.(type) {
case string:
if m, ok := val.(map[string]interface{}); ok {
val, ok = m[typedField]
if !ok {
// not in map
return nil, false, nil
}
} else {
return nil, false, InvalidType(fields[:i+1], val, "map[string]interface{}")
}
case int:
if s, ok := val.([]interface{}); ok {
if typedField >= len(s) {
// index out of range
return nil, false, nil
}
val = s[typedField]
} else {
return nil, false, InvalidType(fields[:i+1], val, "[]interface{}")
}
default:
return nil, false, InvalidType(fields[:i+1], val, "string or int")
}
}
return val, true, nil
}
// InvalidType returns a *Error indicating "invalid value type". This is used
// to report malformed values (e.g. found int, expected string).
func InvalidType(fieldPath []interface{}, value interface{}, validTypes string) *field.Error {
return Invalid(fieldPath, value,
fmt.Sprintf("found type %T, expected %s", value, validTypes))
}
// Invalid returns a *Error indicating "invalid value". This is used
// to report malformed values (e.g. failed regex match, too long, out of bounds).
func Invalid(fieldPath []interface{}, value interface{}, detail string) *field.Error {
return &field.Error{
Type: field.ErrorTypeInvalid,
Field: FieldPath(fieldPath),
BadValue: value,
Detail: detail,
}
}
// NotFound returns a *Error indicating "value not found". This is
// used to report failure to find a requested value (e.g. looking up an ID).
func NotFound(fieldPath []interface{}, value interface{}) *field.Error {
return &field.Error{
Type: field.ErrorTypeNotFound,
Field: FieldPath(fieldPath),
BadValue: value,
Detail: "",
}
}
// FieldPath formats a list of KRM field keys as a JSONPath expression.
// The only valid field keys in KRM are strings (map keys) and ints (list keys).
// Simple strings (see isSimpleString) will be delimited with a period.
// Complex strings will be wrapped with square brackets and double quotes.
// Integers will be wrapped with square brackets.
// All other types will be formatted best-effort within square brackets.
func FieldPath(fieldPath []interface{}) string {
var sb strings.Builder
for _, field := range fieldPath {
switch typedField := field.(type) {
case string:
if isSimpleString(typedField) {
_, _ = fmt.Fprintf(&sb, ".%s", typedField)
} else {
_, _ = fmt.Fprintf(&sb, "[%q]", typedField)
}
case int:
_, _ = fmt.Fprintf(&sb, "[%d]", typedField)
default:
// invalid type. try anyway...
_, _ = fmt.Fprintf(&sb, "[%#v]", typedField)
}
}
return sb.String()
}
var simpleStringRegex = regexp.MustCompile(`^[a-zA-Z]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`)
// isSimpleString returns true if the input follows the following rules:
// - contains only alphanumeric characters, '_' or '-'
// - starts with an alphabetic character
// - ends with an alphanumeric character
func isSimpleString(s string) bool {
return simpleStringRegex.FindString(s) != ""
}
+91
View File
@@ -0,0 +1,91 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package object
import (
"fmt"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/cli-runtime/pkg/resource"
"sigs.k8s.io/kustomize/kyaml/kio/kioutil"
)
// InfosToObjMetas returns object metadata (ObjMetadata) for the
// passed objects (infos); returns an error if one occurs.
func InfosToObjMetas(infos []*resource.Info) ([]ObjMetadata, error) {
objMetas := make([]ObjMetadata, 0, len(infos))
for _, info := range infos {
objMeta, err := InfoToObjMeta(info)
if err != nil {
return nil, err
}
objMetas = append(objMetas, objMeta)
}
return objMetas, nil
}
// InfoToObjMeta takes information from the provided info and
// returns an ObjMetadata that identifies the resource.
func InfoToObjMeta(info *resource.Info) (ObjMetadata, error) {
if info == nil || info.Object == nil {
return ObjMetadata{}, fmt.Errorf("attempting to transform info, but it is empty")
}
id := ObjMetadata{
Namespace: info.Namespace,
Name: info.Name,
GroupKind: info.Object.GetObjectKind().GroupVersionKind().GroupKind(),
}
return id, nil
}
// InfoToUnstructured transforms the passed info object into unstructured format.
func InfoToUnstructured(info *resource.Info) *unstructured.Unstructured {
return info.Object.(*unstructured.Unstructured)
}
// UnstructuredToInfo transforms the passed Unstructured object into Info format,
// or an error if one occurs.
func UnstructuredToInfo(obj *unstructured.Unstructured) (*resource.Info, error) {
// make a copy of the input object to avoid modifying the input
obj = obj.DeepCopy()
annos := obj.GetAnnotations()
source := "unstructured"
path, ok := annos[kioutil.PathAnnotation]
if ok {
source = path
}
StripKyamlAnnotations(obj)
return &resource.Info{
Name: obj.GetName(),
Namespace: obj.GetNamespace(),
Source: source,
Object: obj,
}, nil
}
// InfosToUnstructureds transforms the passed objects in Info format to Unstructured.
func InfosToUnstructureds(infos []*resource.Info) []*unstructured.Unstructured {
var objs []*unstructured.Unstructured
for _, info := range infos {
objs = append(objs, InfoToUnstructured(info))
}
return objs
}
// UnstructuredsToInfos transforms the passed Unstructured objects into Info format
// or an error if one occurs.
func UnstructuredsToInfos(objs []*unstructured.Unstructured) ([]*resource.Info, error) {
var infos []*resource.Info
for _, obj := range objs {
inf, err := UnstructuredToInfo(obj)
if err != nil {
return infos, err
}
infos = append(infos, inf)
}
return infos, nil
}
+147
View File
@@ -0,0 +1,147 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
//
// ObjMetadata is the minimal set of information to
// uniquely identify an object. The four fields are:
//
// Group/Kind (NOTE: NOT version)
// Namespace
// Name
//
// We specifically do not use the "version", because
// the APIServer does not recognize a version as a
// different resource. This metadata is used to identify
// resources for pruning and teardown.
package object
import (
"fmt"
"strings"
rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
const (
// Separates inventory fields. This string is allowable as a
// ConfigMap key, but it is not allowed as a character in
// resource name.
fieldSeparator = "_"
// Transform colons in the RBAC resource names to double
// underscore.
colonTranscoded = "__"
)
var (
NilObjMetadata = ObjMetadata{}
)
// RBACGroupKind is a map of the RBAC resources. Needed since name validation
// is different than other k8s resources.
var RBACGroupKind = map[schema.GroupKind]bool{
{Group: rbacv1.GroupName, Kind: "Role"}: true,
{Group: rbacv1.GroupName, Kind: "ClusterRole"}: true,
{Group: rbacv1.GroupName, Kind: "RoleBinding"}: true,
{Group: rbacv1.GroupName, Kind: "ClusterRoleBinding"}: true,
}
// ObjMetadata organizes and stores the indentifying information
// for an object. This struct (as a string) is stored in a
// inventory object to keep track of sets of applied objects.
type ObjMetadata struct {
Namespace string
Name string
GroupKind schema.GroupKind
}
// ParseObjMetadata takes a string, splits it into its four fields,
// and returns an ObjMetadata struct storing the four fields.
// Example inventory string:
//
// test-namespace_test-name_apps_ReplicaSet
//
// Returns an error if unable to parse and create the ObjMetadata struct.
//
// NOTE: name field can contain double underscore (__), which represents
// a colon. RBAC resources can have this additional character (:) in their name.
func ParseObjMetadata(s string) (ObjMetadata, error) {
// Parse first field namespace
index := strings.Index(s, fieldSeparator)
if index == -1 {
return NilObjMetadata, fmt.Errorf("unable to parse stored object metadata: %s", s)
}
namespace := s[:index]
s = s[index+1:]
// Next, parse last field kind
index = strings.LastIndex(s, fieldSeparator)
if index == -1 {
return NilObjMetadata, fmt.Errorf("unable to parse stored object metadata: %s", s)
}
kind := s[index+1:]
s = s[:index]
// Next, parse next to last field group
index = strings.LastIndex(s, fieldSeparator)
if index == -1 {
return NilObjMetadata, fmt.Errorf("unable to parse stored object metadata: %s", s)
}
group := s[index+1:]
// Finally, second field name. Name may contain colon transcoded as double underscore.
name := s[:index]
name = strings.ReplaceAll(name, colonTranscoded, ":")
// Check that there are no extra fields by search for fieldSeparator.
if strings.Contains(name, fieldSeparator) {
return NilObjMetadata, fmt.Errorf("too many fields within: %s", s)
}
// Create the ObjMetadata object from the four parsed fields.
id := ObjMetadata{
Namespace: namespace,
Name: name,
GroupKind: schema.GroupKind{
Group: group,
Kind: kind,
},
}
return id, nil
}
// Equals compares two ObjMetadata and returns true if they are equal. This does
// not contain any special treatment for the extensions API group.
func (o *ObjMetadata) Equals(other *ObjMetadata) bool {
if other == nil {
return false
}
return *o == *other
}
// String create a string version of the ObjMetadata struct. For RBAC resources,
// the "name" field transcodes ":" into double underscore for valid storing
// as the label of a ConfigMap.
func (o ObjMetadata) String() string {
name := o.Name
if _, exists := RBACGroupKind[o.GroupKind]; exists {
name = strings.ReplaceAll(name, ":", colonTranscoded)
}
return fmt.Sprintf("%s%s%s%s%s%s%s",
o.Namespace, fieldSeparator,
name, fieldSeparator,
o.GroupKind.Group, fieldSeparator,
o.GroupKind.Kind)
}
// RuntimeToObjMeta extracts the object metadata information from a
// runtime.Object and returns it as ObjMetadata.
func RuntimeToObjMeta(obj runtime.Object) (ObjMetadata, error) {
accessor, err := meta.Accessor(obj)
if err != nil {
return NilObjMetadata, err
}
id := ObjMetadata{
Namespace: accessor.GetNamespace(),
Name: accessor.GetName(),
GroupKind: obj.GetObjectKind().GroupVersionKind().GroupKind(),
}
return id, nil
}
+211
View File
@@ -0,0 +1,211 @@
// Copyright 2021 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
//
package object
import (
"hash/fnv"
"sort"
"strconv"
)
// ObjMetadataSet is an ordered list of ObjMetadata that acts like an unordered
// set for comparison purposes.
type ObjMetadataSet []ObjMetadata
// UnstructuredSetEquals returns true if the slice of objects in setA equals
// the slice of objects in setB.
func ObjMetadataSetEquals(setA []ObjMetadata, setB []ObjMetadata) bool {
return ObjMetadataSet(setA).Equal(ObjMetadataSet(setB))
}
// ObjMetadataSetFromMap constructs a set from a map
func ObjMetadataSetFromMap(mapA map[ObjMetadata]struct{}) ObjMetadataSet {
setA := make(ObjMetadataSet, 0, len(mapA))
for f := range mapA {
setA = append(setA, f)
}
return setA
}
// Equal returns true if the two sets contain equivalent objects. Duplicates are
// ignored.
// This function satisfies the cmp.Equal interface from github.com/google/go-cmp
func (setA ObjMetadataSet) Equal(setB ObjMetadataSet) bool {
mapA := make(map[ObjMetadata]struct{}, len(setA))
for _, a := range setA {
mapA[a] = struct{}{}
}
mapB := make(map[ObjMetadata]struct{}, len(setB))
for _, b := range setB {
mapB[b] = struct{}{}
}
if len(mapA) != len(mapB) {
return false
}
for b := range mapB {
if _, exists := mapA[b]; !exists {
return false
}
}
return true
}
// Contains checks if the provided ObjMetadata exists in the set.
func (setA ObjMetadataSet) Contains(id ObjMetadata) bool {
for _, om := range setA {
if om == id {
return true
}
}
return false
}
// Remove the object from the set and return the updated set.
func (setA ObjMetadataSet) Remove(obj ObjMetadata) ObjMetadataSet {
for i, a := range setA {
if a == obj {
setA[len(setA)-1], setA[i] = setA[i], setA[len(setA)-1]
return setA[:len(setA)-1]
}
}
return setA
}
// Intersection returns the set of unique objects in both set A and set B.
func (setA ObjMetadataSet) Intersection(setB ObjMetadataSet) ObjMetadataSet {
var maxlen int
if len(setA) > len(setB) {
maxlen = len(setA)
} else {
maxlen = len(setB)
}
mapI := make(map[ObjMetadata]struct{}, maxlen)
mapB := setB.ToMap()
for _, a := range setA {
if _, ok := mapB[a]; ok {
mapI[a] = struct{}{}
}
}
intersection := make(ObjMetadataSet, 0, len(mapI))
// Iterate over setA & setB to retain input order and have stable output
for _, id := range setA {
if _, ok := mapI[id]; ok {
intersection = append(intersection, id)
delete(mapI, id)
}
}
for _, id := range setB {
if _, ok := mapI[id]; ok {
intersection = append(intersection, id)
delete(mapI, id)
}
}
return intersection
}
// Union returns the set of unique objects from the merging of set A and set B.
func (setA ObjMetadataSet) Union(setB ObjMetadataSet) ObjMetadataSet {
m := make(map[ObjMetadata]struct{}, len(setA)+len(setB))
for _, a := range setA {
m[a] = struct{}{}
}
for _, b := range setB {
m[b] = struct{}{}
}
union := make(ObjMetadataSet, 0, len(m))
// Iterate over setA & setB to retain input order and have stable output
for _, id := range setA {
if _, ok := m[id]; ok {
union = append(union, id)
delete(m, id)
}
}
for _, id := range setB {
if _, ok := m[id]; ok {
union = append(union, id)
delete(m, id)
}
}
return union
}
// Diff returns the set of objects that exist in set A, but not in set B (A - B).
func (setA ObjMetadataSet) Diff(setB ObjMetadataSet) ObjMetadataSet {
// Create a map of the elements of A
m := make(map[ObjMetadata]struct{}, len(setA))
for _, a := range setA {
m[a] = struct{}{}
}
// Remove from A each element of B
for _, b := range setB {
delete(m, b) // OK to delete even if b not in m
}
// Create/return slice from the map of remaining items
diff := make(ObjMetadataSet, 0, len(m))
// Iterate over setA to retain input order and have stable output
for _, id := range setA {
if _, ok := m[id]; ok {
diff = append(diff, id)
delete(m, id)
}
}
return diff
}
// Unique returns the set with duplicates removed.
// Order may or may not remain consistent.
func (setA ObjMetadataSet) Unique() ObjMetadataSet {
return ObjMetadataSetFromMap(setA.ToMap())
}
// Hash the objects in the set by serializing, sorting, concatonating, and
// hashing the result with the 32-bit FNV-1a algorithm.
func (setA ObjMetadataSet) Hash() string {
objStrs := make([]string, 0, len(setA))
for _, obj := range setA {
objStrs = append(objStrs, obj.String())
}
sort.Strings(objStrs)
h := fnv.New32a()
for _, obj := range objStrs {
// Hash32.Write never returns an error
// https://pkg.go.dev/hash#pkg-types
_, _ = h.Write([]byte(obj))
}
return strconv.FormatUint(uint64(h.Sum32()), 16)
}
// ToMap returns the set as a map, with objMeta keys and empty struct values.
func (setA ObjMetadataSet) ToMap() map[ObjMetadata]struct{} {
m := make(map[ObjMetadata]struct{}, len(setA))
for _, objMeta := range setA {
m[objMeta] = struct{}{}
}
return m
}
// ToStringMap returns the set as a serializable map, with objMeta keys and
// empty string values.
func (setA ObjMetadataSet) ToStringMap() map[string]string {
stringMap := make(map[string]string, len(setA))
for _, objMeta := range setA {
stringMap[objMeta.String()] = ""
}
return stringMap
}
// FromStringMap returns a set from a serializable map, with objMeta keys and
// empty string values. Errors if parsing fails.
func FromStringMap(in map[string]string) (ObjMetadataSet, error) {
var set ObjMetadataSet
for s := range in {
objMeta, err := ParseObjMetadata(s)
if err != nil {
return nil, err
}
set = append(set, objMeta)
}
return set, nil
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package object
import (
"fmt"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/yaml"
)
// YamlStringer delays YAML marshalling for logging until String() is called.
type YamlStringer struct {
O *unstructured.Unstructured
}
// String marshals the wrapped object to a YAML string. If serializing errors,
// the error string will be returned instead. This is primarily for use with
// verbose logging.
func (ys YamlStringer) String() string {
yamlBytes, err := yaml.Marshal(ys.O)
if err != nil {
return fmt.Sprintf("<<failed to serialize as yaml: %s>>", err)
}
return string(yamlBytes)
}
+214
View File
@@ -0,0 +1,214 @@
// Copyright 2021 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
//
package object
import (
"fmt"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/kustomize/kyaml/kio/kioutil"
)
var (
namespaceGK = schema.GroupKind{Group: "", Kind: "Namespace"}
crdGK = schema.GroupKind{Group: "apiextensions.k8s.io", Kind: "CustomResourceDefinition"}
)
// UnstructuredSetToObjMetadataSet converts a UnstructuredSet to a ObjMetadataSet.
func UnstructuredSetToObjMetadataSet(objs UnstructuredSet) ObjMetadataSet {
objMetas := make([]ObjMetadata, len(objs))
for i, obj := range objs {
objMetas[i] = UnstructuredToObjMetadata(obj)
}
return objMetas
}
// UnstructuredToObjMetadata extracts the identifying information from an
// Unstructured object and returns it as ObjMetadata object.
func UnstructuredToObjMetadata(obj *unstructured.Unstructured) ObjMetadata {
return ObjMetadata{
Namespace: obj.GetNamespace(),
Name: obj.GetName(),
GroupKind: obj.GroupVersionKind().GroupKind(),
}
}
// IsKindNamespace returns true if the passed Unstructured object is
// GroupKind == Core/Namespace (no version checked); false otherwise.
func IsKindNamespace(u *unstructured.Unstructured) bool {
if u == nil {
return false
}
gvk := u.GroupVersionKind()
return namespaceGK == gvk.GroupKind()
}
// IsNamespaced returns true if the passed Unstructured object
// is namespace-scoped (not cluster-scoped); false otherwise.
func IsNamespaced(u *unstructured.Unstructured) bool {
if u == nil {
return false
}
return u.GetNamespace() != ""
}
// IsNamespace returns true if the passed Unstructured object
// is Namespace in the core (empty string) group.
func IsNamespace(u *unstructured.Unstructured) bool {
if u == nil {
return false
}
gvk := u.GroupVersionKind()
// core group, any version
return gvk.Group == "" && gvk.Kind == "Namespace"
}
// IsCRD returns true if the passed Unstructured object has
// GroupKind == Extensions/CustomResourceDefinition; false otherwise.
func IsCRD(u *unstructured.Unstructured) bool {
if u == nil {
return false
}
gvk := u.GroupVersionKind()
return crdGK == gvk.GroupKind()
}
// GetCRDGroupKind returns the GroupKind stored in the passed
// Unstructured CustomResourceDefinition and true if the passed object
// is a CRD.
func GetCRDGroupKind(u *unstructured.Unstructured) (schema.GroupKind, bool) {
emptyGroupKind := schema.GroupKind{Group: "", Kind: ""}
if u == nil {
return emptyGroupKind, false
}
group, found, err := unstructured.NestedString(u.Object, "spec", "group")
if found && err == nil {
kind, found, err := unstructured.NestedString(u.Object, "spec", "names", "kind")
if found && err == nil {
return schema.GroupKind{Group: group, Kind: kind}, true
}
}
return emptyGroupKind, false
}
// UnknownTypeError captures information about a type for which no information
// could be found in the cluster or among the known CRDs.
type UnknownTypeError struct {
GroupVersionKind schema.GroupVersionKind
}
func (e *UnknownTypeError) Error() string {
return fmt.Sprintf("unknown resource type: %q", e.GroupVersionKind.String())
}
// LookupResourceScope tries to look up the scope of the type of the provided
// resource, looking at both the types known to the cluster (through the
// RESTMapper) and the provided CRDs. If no information about the type can
// be found, an UnknownTypeError wil be returned.
func LookupResourceScope(u *unstructured.Unstructured, crds []*unstructured.Unstructured, mapper meta.RESTMapper) (meta.RESTScope, error) {
gvk := u.GroupVersionKind()
// First see if we can find the type (and the scope) in the cluster through
// the RESTMapper.
mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
if err == nil {
// If we find the type in the cluster, we just look up the scope there.
return mapping.Scope, nil
}
// Not finding a match is not an error here, so only error out for other
// error types.
if !meta.IsNoMatchError(err) {
return nil, err
}
// If we couldn't find the type in the cluster, check if we find a
// match in any of the provided CRDs.
for _, crd := range crds {
group, found, err := NestedField(crd.Object, "spec", "group")
if err != nil {
return nil, err
}
if !found || group == "" {
return nil, NotFound([]interface{}{"spec", "group"}, group)
}
kind, found, err := NestedField(crd.Object, "spec", "names", "kind")
if err != nil {
return nil, err
}
if !found || kind == "" {
return nil, NotFound([]interface{}{"spec", "kind"}, group)
}
if gvk.Kind != kind || gvk.Group != group {
continue
}
versionDefined, err := crdDefinesVersion(crd, gvk.Version)
if err != nil {
return nil, err
}
if !versionDefined {
return nil, &UnknownTypeError{
GroupVersionKind: gvk,
}
}
scopeName, _, err := NestedField(crd.Object, "spec", "scope")
if err != nil {
return nil, err
}
switch scopeName {
case "Namespaced":
return meta.RESTScopeNamespace, nil
case "Cluster":
return meta.RESTScopeRoot, nil
default:
return nil, Invalid([]interface{}{"spec", "scope"}, scopeName,
"expected Namespaced or Cluster")
}
}
return nil, &UnknownTypeError{
GroupVersionKind: gvk,
}
}
func crdDefinesVersion(crd *unstructured.Unstructured, version string) (bool, error) {
versions, found, err := NestedField(crd.Object, "spec", "versions")
if err != nil {
return false, err
}
if !found {
return false, NotFound([]interface{}{"spec", "versions"}, versions)
}
versionsSlice, ok := versions.([]interface{})
if !ok {
return false, InvalidType([]interface{}{"spec", "versions"}, versions, "[]interface{}")
}
if len(versionsSlice) == 0 {
return false, Invalid([]interface{}{"spec", "versions"}, versionsSlice, "must not be empty")
}
for i := range versionsSlice {
name, found, err := NestedField(crd.Object, "spec", "versions", i, "name")
if err != nil {
return false, err
}
if !found {
return false, NotFound([]interface{}{"spec", "versions", i, "name"}, name)
}
if name == version {
return true, nil
}
}
return false, nil
}
// StripKyamlAnnotations removes any path and index annotations from the
// unstructured resource.
func StripKyamlAnnotations(u *unstructured.Unstructured) {
annos := u.GetAnnotations()
delete(annos, kioutil.PathAnnotation)
delete(annos, kioutil.LegacyPathAnnotation) //nolint:staticcheck
delete(annos, kioutil.IndexAnnotation)
delete(annos, kioutil.LegacyIndexAnnotation) //nolint:staticcheck
u.SetAnnotations(annos)
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright 2021 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
//
package object
import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// UnstructuredSet is an ordered list of Unstructured that acts like an
// unordered set for comparison purposes.
type UnstructuredSet []*unstructured.Unstructured
// UnstructuredSetEquals returns true if the slice of objects in setA equals
// the slice of objects in setB.
func UnstructuredSetEquals(setA []*unstructured.Unstructured, setB []*unstructured.Unstructured) bool {
return UnstructuredSet(setA).Equal(UnstructuredSet(setB))
}
func (setA UnstructuredSet) Equal(setB UnstructuredSet) bool {
mapA := make(map[string]string, len(setA))
for _, a := range setA {
jsonBytes, err := a.MarshalJSON()
if err != nil {
mapA[string(jsonBytes)] = err.Error()
} else {
mapA[string(jsonBytes)] = ""
}
}
mapB := make(map[string]string, len(setB))
for _, b := range setB {
jsonBytes, err := b.MarshalJSON()
if err != nil {
mapB[string(jsonBytes)] = err.Error()
} else {
mapB[string(jsonBytes)] = ""
}
}
if len(mapA) != len(mapB) {
return false
}
for b, errB := range mapB {
if errA, exists := mapA[b]; !exists {
if !exists {
return false
}
if errA != errB {
return false
}
}
}
return true
}