From ceccf781a0ee63aed269d12e76f2766d120cb86f Mon Sep 17 00:00:00 2001 From: Andrew Bayer Date: Thu, 25 Aug 2022 14:53:15 -0400 Subject: [PATCH] Move over the resolver framework from Resolution. Part of #4710 As with #5372, little to no code changes at this point. Signed-off-by: Andrew Bayer --- .../resolver/framework/configstore.go | 86 ++++++ .../resolver/framework/configstore_test.go | 92 ++++++ .../resolver/framework/controller.go | 195 +++++++++++++ .../resolver/framework/fakeresolver.go | 148 ++++++++++ .../resolver/framework/interface.go | 97 +++++++ .../resolver/framework/reconciler.go | 222 +++++++++++++++ .../resolver/framework/reconciler_test.go | 264 ++++++++++++++++++ .../framework/testing/fakecontroller.go | 127 +++++++++ 8 files changed, 1231 insertions(+) create mode 100644 pkg/resolution/resolver/framework/configstore.go create mode 100644 pkg/resolution/resolver/framework/configstore_test.go create mode 100644 pkg/resolution/resolver/framework/controller.go create mode 100644 pkg/resolution/resolver/framework/fakeresolver.go create mode 100644 pkg/resolution/resolver/framework/interface.go create mode 100644 pkg/resolution/resolver/framework/reconciler.go create mode 100644 pkg/resolution/resolver/framework/reconciler_test.go create mode 100644 pkg/resolution/resolver/framework/testing/fakecontroller.go diff --git a/pkg/resolution/resolver/framework/configstore.go b/pkg/resolution/resolver/framework/configstore.go new file mode 100644 index 00000000000..8e200eba1d3 --- /dev/null +++ b/pkg/resolution/resolver/framework/configstore.go @@ -0,0 +1,86 @@ +/* + Copyright 2022 The Tekton Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package framework + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + "knative.dev/pkg/configmap" +) + +// resolverConfigKey is the contenxt key associated with configuration +// for one specific resolver, and is only used if that resolver +// implements the optional framework.ConfigWatcher interface. +var resolverConfigKey = struct{}{} + +// DataFromConfigMap returns a copy of the contents of a configmap or an +// empty map if the configmap doesn't have any data. +func DataFromConfigMap(config *corev1.ConfigMap) (map[string]string, error) { + resolverConfig := map[string]string{} + if config == nil { + return resolverConfig, nil + } + for key, value := range config.Data { + resolverConfig[key] = value + } + return resolverConfig, nil +} + +// ConfigStore wraps a knative untyped store and provides helper methods +// for working with a resolver's configuration data. +type ConfigStore struct { + resolverConfigName string + untyped *configmap.UntypedStore +} + +// GetResolverConfig returns a copy of the resolver's current +// configuration or an empty map if the stored config is nil or invalid. +func (store *ConfigStore) GetResolverConfig() map[string]string { + resolverConfig := map[string]string{} + untypedConf := store.untyped.UntypedLoad(store.resolverConfigName) + if conf, ok := untypedConf.(map[string]string); ok { + for key, val := range conf { + resolverConfig[key] = val + } + } + return resolverConfig +} + +// ToContext returns a new context with the resolver's configuration +// data stored in it. +func (store *ConfigStore) ToContext(ctx context.Context) context.Context { + conf := store.GetResolverConfig() + return InjectResolverConfigToContext(ctx, conf) +} + +// InjectResolverConfigToContext returns a new context with a +// map stored in it for a resolvers config. +func InjectResolverConfigToContext(ctx context.Context, conf map[string]string) context.Context { + return context.WithValue(ctx, resolverConfigKey, conf) +} + +// GetResolverConfigFromContext returns any resolver-specific +// configuration that has been stored or an empty map if none exists. +func GetResolverConfigFromContext(ctx context.Context) map[string]string { + conf := map[string]string{} + storedConfig := ctx.Value(resolverConfigKey) + if resolverConfig, ok := storedConfig.(map[string]string); ok { + conf = resolverConfig + } + return conf +} diff --git a/pkg/resolution/resolver/framework/configstore_test.go b/pkg/resolution/resolver/framework/configstore_test.go new file mode 100644 index 00000000000..34bc82e39d3 --- /dev/null +++ b/pkg/resolution/resolver/framework/configstore_test.go @@ -0,0 +1,92 @@ +/* + Copyright 2022 The Tekton Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package framework + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + "knative.dev/pkg/configmap" + logtesting "knative.dev/pkg/logging/testing" +) + +// TestDataFromConfigMap checks that configmaps are correctly converted +// into a map[string]string +func TestDataFromConfigMap(t *testing.T) { + for _, tc := range []struct { + configMap *corev1.ConfigMap + expected map[string]string + }{{ + configMap: nil, + expected: map[string]string{}, + }, { + configMap: &corev1.ConfigMap{ + Data: nil, + }, + expected: map[string]string{}, + }, { + configMap: &corev1.ConfigMap{ + Data: map[string]string{}, + }, + expected: map[string]string{}, + }, { + configMap: &corev1.ConfigMap{ + Data: map[string]string{ + "foo": "bar", + }, + }, + expected: map[string]string{ + "foo": "bar", + }, + }} { + out, err := DataFromConfigMap(tc.configMap) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !mapsAreEqual(tc.expected, out) { + t.Fatalf("expected %#v received %#v", tc.expected, out) + } + } +} + +func TestGetResolverConfig(t *testing.T) { + _ = &ConfigStore{ + resolverConfigName: "test", + untyped: configmap.NewUntypedStore( + "test-config", + logtesting.TestLogger(t), + configmap.Constructors{ + "test": DataFromConfigMap, + }, + ), + } +} + +func mapsAreEqual(m1, m2 map[string]string) bool { + if m1 == nil || m2 == nil { + return m1 == nil && m2 == nil + } + if len(m1) != len(m2) { + return false + } + for k, v := range m1 { + if m2[k] != v { + return false + } + } + return true +} diff --git a/pkg/resolution/resolver/framework/controller.go b/pkg/resolution/resolver/framework/controller.go new file mode 100644 index 00000000000..5f2ffcdbb87 --- /dev/null +++ b/pkg/resolution/resolver/framework/controller.go @@ -0,0 +1,195 @@ +/* +Copyright 2022 The Tekton Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package framework + +import ( + "context" + "fmt" + "strings" + + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1alpha1" + rrclient "github.com/tektoncd/pipeline/pkg/client/resolution/injection/client" + rrinformer "github.com/tektoncd/pipeline/pkg/client/resolution/injection/informers/resolution/v1alpha1/resolutionrequest" + rrlister "github.com/tektoncd/pipeline/pkg/client/resolution/listers/resolution/v1alpha1" + "github.com/tektoncd/pipeline/pkg/resolution/common" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/cache" + "k8s.io/utils/clock" + kubeclient "knative.dev/pkg/client/injection/kube/client" + "knative.dev/pkg/configmap" + "knative.dev/pkg/controller" + "knative.dev/pkg/logging" + "knative.dev/pkg/reconciler" +) + +// ReconcilerModifier is a func that can access and modify a reconciler +// in the moments before a resolver is started. It allows for +// things like injecting a test clock. +type ReconcilerModifier = func(reconciler *Reconciler) + +// NewController returns a knative controller for a Tekton Resolver. +// This sets up a lot of the boilerplate that individual resolvers +// shouldn't need to be concerned with since it's common to all of them. +func NewController(ctx context.Context, resolver Resolver, modifiers ...ReconcilerModifier) func(context.Context, configmap.Watcher) *controller.Impl { + if err := validateResolver(ctx, resolver); err != nil { + panic(err.Error()) + } + return func(ctx context.Context, cmw configmap.Watcher) *controller.Impl { + logger := logging.FromContext(ctx) + kubeclientset := kubeclient.Get(ctx) + rrclientset := rrclient.Get(ctx) + rrInformer := rrinformer.Get(ctx) + + if err := resolver.Initialize(ctx); err != nil { + panic(err.Error()) + } + + r := &Reconciler{ + LeaderAwareFuncs: leaderAwareFuncs(rrInformer.Lister()), + kubeClientSet: kubeclientset, + resolutionRequestLister: rrInformer.Lister(), + resolutionRequestClientSet: rrclientset, + resolver: resolver, + } + + watchConfigChanges(ctx, r, cmw) + + // TODO(sbwsg): Do better sanitize. + resolverName := resolver.GetName(ctx) + resolverName = strings.ReplaceAll(resolverName, "/", "") + resolverName = strings.ReplaceAll(resolverName, " ", "") + + applyModifiersAndDefaults(ctx, r, modifiers) + + impl := controller.NewContext(ctx, r, controller.ControllerOptions{ + WorkQueueName: "TektonResolverFramework." + resolverName, + Logger: logger, + }) + + rrInformer.Informer().AddEventHandler(cache.FilteringResourceEventHandler{ + FilterFunc: filterResolutionRequestsBySelector(resolver.GetSelector(ctx)), + Handler: cache.ResourceEventHandlerFuncs{ + AddFunc: impl.Enqueue, + UpdateFunc: func(oldObj, newObj interface{}) { + impl.Enqueue(newObj) + }, + // TODO(sbwsg): should we deliver delete events + // to the resolver? + // DeleteFunc: impl.Enqueue, + }, + }) + + return impl + } +} + +func filterResolutionRequestsBySelector(selector map[string]string) func(obj interface{}) bool { + return func(obj interface{}) bool { + rr, ok := obj.(*v1alpha1.ResolutionRequest) + if !ok { + return false + } + if len(rr.ObjectMeta.Labels) == 0 { + return false + } + for key, val := range selector { + lookup, has := rr.ObjectMeta.Labels[key] + if !has { + return false + } + if lookup != val { + return false + } + } + return true + } +} + +// TODO(sbwsg): I don't really understand the LeaderAwareness types beyond the +// fact that the controller crashes if they're missing. It looks +// like this is bucketing based on labels. Should we use the filter +// selector from above in the call to lister.List here? +func leaderAwareFuncs(lister rrlister.ResolutionRequestLister) reconciler.LeaderAwareFuncs { + return reconciler.LeaderAwareFuncs{ + PromoteFunc: func(bkt reconciler.Bucket, enq func(reconciler.Bucket, types.NamespacedName)) error { + all, err := lister.List(labels.Everything()) + if err != nil { + return err + } + for _, elt := range all { + enq(bkt, types.NamespacedName{ + Namespace: elt.GetNamespace(), + Name: elt.GetName(), + }) + } + return nil + }, + } +} + +// ErrorMissingTypeSelector is returned when a resolver does not return +// a selector with a type label from its GetSelector method. +var ErrorMissingTypeSelector = fmt.Errorf("invalid resolver: minimum selector must include %q", common.LabelKeyResolverType) + +func validateResolver(ctx context.Context, r Resolver) error { + sel := r.GetSelector(ctx) + if sel == nil { + return ErrorMissingTypeSelector + } + if sel[common.LabelKeyResolverType] == "" { + return ErrorMissingTypeSelector + } + return nil +} + +// watchConfigChanges binds a framework.Resolver to updates on its +// configmap, using knative's configmap helpers. This is only done if +// the resolver implements the framework.ConfigWatcher interface. +func watchConfigChanges(ctx context.Context, reconciler *Reconciler, cmw configmap.Watcher) { + if configWatcher, ok := reconciler.resolver.(ConfigWatcher); ok { + logger := logging.FromContext(ctx) + resolverConfigName := configWatcher.GetConfigName(ctx) + if resolverConfigName == "" { + panic("resolver returned empty config name") + } + reconciler.configStore = &ConfigStore{ + resolverConfigName: resolverConfigName, + untyped: configmap.NewUntypedStore( + "resolver-config", + logger, + configmap.Constructors{ + resolverConfigName: DataFromConfigMap, + }, + ), + } + reconciler.configStore.untyped.WatchConfigs(cmw) + } +} + +// applyModifiersAndDefaults applies the given modifiers to +// a reconciler and, after doing so, sets any default values for things +// that weren't set by a modifier. +func applyModifiersAndDefaults(ctx context.Context, r *Reconciler, modifiers []ReconcilerModifier) { + for _, mod := range modifiers { + mod(r) + } + + if r.Clock == nil { + r.Clock = clock.RealClock{} + } +} diff --git a/pkg/resolution/resolver/framework/fakeresolver.go b/pkg/resolution/resolver/framework/fakeresolver.go new file mode 100644 index 00000000000..40987e5e85e --- /dev/null +++ b/pkg/resolution/resolver/framework/fakeresolver.go @@ -0,0 +1,148 @@ +/* + Copyright 2022 The Tekton Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package framework + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" +) + +const ( + // LabelValueFakeResolverType is the value to use for the + // resolution.tekton.dev/type label on resource requests + LabelValueFakeResolverType string = "fake" + + // FakeResolverName is the name that the fake resolver should be + // associated with + FakeResolverName string = "Fake" + + // FakeParamName is the name used for the fake resolver's single parameter. + FakeParamName string = "fake-key" +) + +var _ Resolver = &FakeResolver{} + +// FakeResolvedResource is a framework.ResolvedResource implementation for use with the fake resolver. +// If it's the value in the FakeResolver's ForParam map for the key given as the fake param value, the FakeResolver will +// first check if it's got a value for ErrorWith. If so, that string will be returned as an error. Then, if WaitFor is +// greater than zero, the FakeResolver will wait that long before returning. And finally, the FakeResolvedResource will +// be returned. +type FakeResolvedResource struct { + Content string + AnnotationMap map[string]string + ErrorWith string + WaitFor time.Duration +} + +// Data returns the FakeResolvedResource's Content field as bytes. +func (f *FakeResolvedResource) Data() []byte { + return []byte(f.Content) +} + +// Annotations returns the FakeResolvedResource's AnnotationMap field. +func (f *FakeResolvedResource) Annotations() map[string]string { + return f.AnnotationMap +} + +// FakeResolver implements a framework.Resolver that can fetch pre-configured strings based on a parameter value, or return +// resolution attempts with a configured error. +type FakeResolver struct { + ForParam map[string]*FakeResolvedResource + Timeout time.Duration +} + +// Initialize performs any setup required by the fake resolver. +func (r *FakeResolver) Initialize(ctx context.Context) error { + if r.ForParam == nil { + r.ForParam = make(map[string]*FakeResolvedResource) + } + return nil +} + +// GetName returns the string name that the fake resolver should be +// associated with. +func (r *FakeResolver) GetName(_ context.Context) string { + return FakeResolverName +} + +// GetSelector returns the labels that resource requests are required to have for +// the fake resolver to process them. +func (r *FakeResolver) GetSelector(_ context.Context) map[string]string { + return map[string]string{ + resolutioncommon.LabelKeyResolverType: LabelValueFakeResolverType, + } +} + +// ValidateParams returns an error if the given parameter map is not +// valid for a resource request targeting the fake resolver. +func (r *FakeResolver) ValidateParams(_ context.Context, params map[string]string) error { + required := []string{ + FakeParamName, + } + missing := []string{} + if params == nil { + missing = required + } else { + for _, p := range required { + v, has := params[p] + if !has || v == "" { + missing = append(missing, p) + } + } + } + if len(missing) > 0 { + return fmt.Errorf("missing %v", strings.Join(missing, ", ")) + } + + return nil +} + +// Resolve performs the work of fetching a file from the fake resolver given a map of +// parameters. +func (r *FakeResolver) Resolve(_ context.Context, params map[string]string) (ResolvedResource, error) { + paramValue := params[FakeParamName] + + frr, ok := r.ForParam[paramValue] + if !ok { + return nil, fmt.Errorf("couldn't find resource for param value %s", paramValue) + } + + if frr.ErrorWith != "" { + return nil, errors.New(frr.ErrorWith) + } + + if frr.WaitFor.Seconds() > 0 { + time.Sleep(frr.WaitFor) + } + + return frr, nil +} + +var _ TimedResolution = &FakeResolver{} + +// GetResolutionTimeout returns the configured timeout for the reconciler, or the default time.Duration if not configured. +func (r *FakeResolver) GetResolutionTimeout(ctx context.Context, defaultTimeout time.Duration) time.Duration { + if r.Timeout > 0 { + return r.Timeout + } + return defaultTimeout +} diff --git a/pkg/resolution/resolver/framework/interface.go b/pkg/resolution/resolver/framework/interface.go new file mode 100644 index 00000000000..118f7a70f57 --- /dev/null +++ b/pkg/resolution/resolver/framework/interface.go @@ -0,0 +1,97 @@ +/* +Copyright 2022 The Tekton Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package framework + +import ( + "context" + "time" +) + +// Resolver is the interface to implement for type-specific resource +// resolution. It fetches resources from a given type of remote location +// and returns their content along with any associated annotations. +type Resolver interface { + // Initialize is called at the moment the resolver controller is + // instantiated and is a good place to setup things like + // resource listers. + Initialize(context.Context) error + + // GetName should give back the name of the resolver. E.g. "Git" + GetName(context.Context) string + + // GetSelector returns the labels that are used to direct resolution + // requests to this resolver. + GetSelector(context.Context) map[string]string + + // ValidateParams is given the parameters from a resource + // request and should return an error if any are missing or invalid. + ValidateParams(context.Context, map[string]string) error + + // Resolve receives the parameters passed via a resource request + // and returns the resolved data along with any annotations + // to include in the response. If resolution fails then an error + // should be returned instead. If a resolution.Error + // is returned then its Reason and Message are used as part of the + // response to the request. + Resolve(context.Context, map[string]string) (ResolvedResource, error) +} + +// ConfigWatcher is the interface to implement if your resolver accepts +// additional configuration from an admin. Examples of how this +// might be used: +// - your resolver might require an allow-list of repositories or registries +// - your resolver might allow request timeout settings to be configured +// - your resolver might need an API endpoint or base url to be set +// +// When your resolver implements this interface it will be able to +// access configuration from the context it receives in calls to +// ValidateParams and Resolve. +type ConfigWatcher interface { + // GetConfigName should return a string name for its + // configuration to be referenced by. This will map to the name + // of a ConfigMap in the same namespace as the resolver. + GetConfigName(context.Context) string +} + +// TimedResolution is an optional interface that a resolver can +// implement to override the default resolution request timeout. +// +// There are two timeouts that a resolution request adheres to: First +// there is a global timeout that the core ResolutionRequest reconciler +// enforces on _all_ requests. This prevents zombie requests (such as +// those with a misconfigured `type`) sticking around in perpetuity. +// Second there are resolver-specific timeouts that default to 1 minute. +// +// A resolver implemeting the TimedResolution interface sets the maximum +// duration of any single request to this resolver. +// +// The core ResolutionRequest reconciler's global timeout overrides any +// resolver-specific timeout. +type TimedResolution interface { + // GetResolutionTimeout receives the current request's context + // object, which includes any request-scoped data like + // resolver config and the request's originating namespace, + // along with a default. + GetResolutionTimeout(context.Context, time.Duration) time.Duration +} + +// ResolvedResource returns the data and annotations of a successful +// resource fetch. +type ResolvedResource interface { + Data() []byte + Annotations() map[string]string +} diff --git a/pkg/resolution/resolver/framework/reconciler.go b/pkg/resolution/resolver/framework/reconciler.go new file mode 100644 index 00000000000..be03ae57975 --- /dev/null +++ b/pkg/resolution/resolver/framework/reconciler.go @@ -0,0 +1,222 @@ +/* +Copyright 2022 The Tekton Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package framework + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1alpha1" + rrclient "github.com/tektoncd/pipeline/pkg/client/resolution/clientset/versioned" + rrv1alpha1 "github.com/tektoncd/pipeline/pkg/client/resolution/listers/resolution/v1alpha1" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" + "k8s.io/utils/clock" + "knative.dev/pkg/controller" + "knative.dev/pkg/logging" + "knative.dev/pkg/reconciler" +) + +// Reconciler handles ResolutionRequest objects, performs functionality +// common to all resolvers and delegates resolver-specific actions +// to its embedded type-specific Resolver object. +type Reconciler struct { + // Implements reconciler.LeaderAware + reconciler.LeaderAwareFuncs + + // Clock is used by the reconciler to track the passage of time + // and can be overridden for tests. + Clock clock.PassiveClock + + resolver Resolver + kubeClientSet kubernetes.Interface + resolutionRequestLister rrv1alpha1.ResolutionRequestLister + resolutionRequestClientSet rrclient.Interface + + configStore *ConfigStore +} + +var _ reconciler.LeaderAware = &Reconciler{} + +// defaultMaximumResolutionDuration is the maximum amount of time +// resolution may take. + +// defaultMaximumResolutionDuration is the max time that a call to +// Resolve() may take. It can be overridden by a resolver implementing +// the framework.TimedResolution interface. +const defaultMaximumResolutionDuration = time.Minute + +// Reconcile receives the string key of a ResolutionRequest object, looks +// it up, checks it for common errors, and then delegates +// resolver-specific functionality to the reconciler's embedded +// type-specific resolver. Any errors that occur during validation or +// resolution are handled by updating or failing the ResolutionRequest. +func (r *Reconciler) Reconcile(ctx context.Context, key string) error { + namespace, name, err := cache.SplitMetaNamespaceKey(key) + if err != nil { + err = &resolutioncommon.ErrorInvalidResourceKey{Key: key, Original: err} + return controller.NewPermanentError(err) + } + + rr, err := r.resolutionRequestLister.ResolutionRequests(namespace).Get(name) + if err != nil { + err := &resolutioncommon.ErrorGettingResource{ResolverName: "resolutionrequest", Key: key, Original: err} + return controller.NewPermanentError(err) + } + + if rr.IsDone() { + return nil + } + + // Inject request-scoped information into the context, such as + // the namespace that the request originates from and the + // configuration from the configmap this resolver is watching. + ctx = resolutioncommon.InjectRequestNamespace(ctx, namespace) + if r.configStore != nil { + ctx = r.configStore.ToContext(ctx) + } + + return r.resolve(ctx, key, rr) +} + +func (r *Reconciler) resolve(ctx context.Context, key string, rr *v1alpha1.ResolutionRequest) error { + errChan := make(chan error) + resourceChan := make(chan ResolvedResource) + + timeoutDuration := defaultMaximumResolutionDuration + if timed, ok := r.resolver.(TimedResolution); ok { + timeoutDuration = timed.GetResolutionTimeout(ctx, defaultMaximumResolutionDuration) + } + + // A new context is created for resolution so that timeouts can + // be enforced without affecting other uses of ctx (e.g. sending + // Updates to ResolutionRequest objects). + resolutionCtx, cancelFn := context.WithTimeout(ctx, timeoutDuration) + defer cancelFn() + + go func() { + validationError := r.resolver.ValidateParams(resolutionCtx, rr.Spec.Parameters) + if validationError != nil { + errChan <- &resolutioncommon.ErrorInvalidRequest{ + ResolutionRequestKey: key, + Message: validationError.Error(), + } + return + } + resource, resolveErr := r.resolver.Resolve(resolutionCtx, rr.Spec.Parameters) + if resolveErr != nil { + errChan <- &resolutioncommon.ErrorGettingResource{ + ResolverName: r.resolver.GetName(resolutionCtx), + Key: key, + Original: resolveErr, + } + return + } + resourceChan <- resource + }() + + select { + case err := <-errChan: + if err != nil { + return r.OnError(ctx, rr, err) + } + case <-resolutionCtx.Done(): + if err := resolutionCtx.Err(); err != nil { + return r.OnError(ctx, rr, err) + } + case resource := <-resourceChan: + return r.writeResolvedData(ctx, rr, resource) + } + + return errors.New("unknown error") +} + +// OnError is used to handle any situation where a ResolutionRequest has +// reached a terminal situation that cannot be recovered from. +func (r *Reconciler) OnError(ctx context.Context, rr *v1alpha1.ResolutionRequest, err error) error { + if rr == nil { + return controller.NewPermanentError(err) + } + if err != nil { + _ = r.MarkFailed(ctx, rr, err) + return controller.NewPermanentError(err) + } + return nil +} + +// MarkFailed updates a ResolutionRequest as having failed. It returns +// errors that occur during the update process or nil if the update +// appeared to succeed. +func (r *Reconciler) MarkFailed(ctx context.Context, rr *v1alpha1.ResolutionRequest, resolutionErr error) error { + key := fmt.Sprintf("%s/%s", rr.Namespace, rr.Name) + reason, resolutionErr := resolutioncommon.ReasonError(resolutionErr) + latestGeneration, err := r.resolutionRequestClientSet.ResolutionV1alpha1().ResolutionRequests(rr.Namespace).Get(ctx, rr.Name, metav1.GetOptions{}) + if err != nil { + logging.FromContext(ctx).Warnf("error getting latest generation of resolutionrequest %q: %v", key, err) + return err + } + if latestGeneration.IsDone() { + return nil + } + latestGeneration.Status.MarkFailed(reason, resolutionErr.Error()) + _, err = r.resolutionRequestClientSet.ResolutionV1alpha1().ResolutionRequests(rr.Namespace).UpdateStatus(ctx, latestGeneration, metav1.UpdateOptions{}) + if err != nil { + logging.FromContext(ctx).Warnf("error marking resolutionrequest %q as failed: %v", key, err) + return err + } + return nil +} + +// statusDataPatch is the json structure that will be PATCHed into +// a ResolutionRequest with its data and annotations once successfully +// resolved. +type statusDataPatch struct { + Annotations map[string]string `json:"annotations"` + Data string `json:"data"` +} + +func (r *Reconciler) writeResolvedData(ctx context.Context, rr *v1alpha1.ResolutionRequest, resource ResolvedResource) error { + encodedData := base64.StdEncoding.Strict().EncodeToString(resource.Data()) + patchBytes, err := json.Marshal(map[string]statusDataPatch{ + "status": { + Data: encodedData, + Annotations: resource.Annotations(), + }, + }) + if err != nil { + return r.OnError(ctx, rr, &resolutioncommon.ErrorUpdatingRequest{ + ResolutionRequestKey: fmt.Sprintf("%s/%s", rr.Namespace, rr.Name), + Original: fmt.Errorf("error serializing resource request patch: %w", err), + }) + } + _, err = r.resolutionRequestClientSet.ResolutionV1alpha1().ResolutionRequests(rr.Namespace).Patch(ctx, rr.Name, types.MergePatchType, patchBytes, metav1.PatchOptions{}, "status") + if err != nil { + return r.OnError(ctx, rr, &resolutioncommon.ErrorUpdatingRequest{ + ResolutionRequestKey: fmt.Sprintf("%s/%s", rr.Namespace, rr.Name), + Original: err, + }) + } + + return nil +} diff --git a/pkg/resolution/resolver/framework/reconciler_test.go b/pkg/resolution/resolver/framework/reconciler_test.go new file mode 100644 index 00000000000..2e9fb9a14eb --- /dev/null +++ b/pkg/resolution/resolver/framework/reconciler_test.go @@ -0,0 +1,264 @@ +/* + Copyright 2022 The Tekton Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package framework + +import ( + "context" + "encoding/base64" + "errors" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1alpha1" + ttesting "github.com/tektoncd/pipeline/pkg/reconciler/testing" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + "github.com/tektoncd/pipeline/test" + "github.com/tektoncd/pipeline/test/diff" + "github.com/tektoncd/pipeline/test/names" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/clock" + "k8s.io/client-go/tools/record" + "knative.dev/pkg/apis" + duckv1 "knative.dev/pkg/apis/duck/v1" + cminformer "knative.dev/pkg/configmap/informer" + "knative.dev/pkg/controller" + "knative.dev/pkg/logging" + pkgreconciler "knative.dev/pkg/reconciler" + "knative.dev/pkg/system" + + _ "knative.dev/pkg/system/testing" // Setup system.Namespace() +) + +var ( + now = time.Date(2022, time.January, 1, 0, 0, 0, 0, time.UTC) + testClock = clock.NewFakePassiveClock(now) + ignoreLastTransitionTime = cmpopts.IgnoreFields(apis.Condition{}, "LastTransitionTime.Inner.Time") +) + +func TestReconcile(t *testing.T) { + testCases := []struct { + name string + inputRequest *v1alpha1.ResolutionRequest + paramMap map[string]*FakeResolvedResource + reconcilerTimeout time.Duration + expectedStatus *v1alpha1.ResolutionRequestStatus + expectedErr error + }{ + { + name: "unknown value", + inputRequest: &v1alpha1.ResolutionRequest{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "resolution.tekton.dev/v1alpha1", + Kind: "ResolutionRequest", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "rr", + Namespace: "foo", + CreationTimestamp: metav1.Time{Time: time.Now()}, + Labels: map[string]string{ + resolutioncommon.LabelKeyResolverType: LabelValueFakeResolverType, + }, + }, + Spec: v1alpha1.ResolutionRequestSpec{ + Parameters: map[string]string{ + FakeParamName: "bar", + }, + }, + Status: v1alpha1.ResolutionRequestStatus{}, + }, + expectedErr: errors.New("error getting \"Fake\" \"foo/rr\": couldn't find resource for param value bar"), + }, { + name: "known value", + inputRequest: &v1alpha1.ResolutionRequest{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "resolution.tekton.dev/v1alpha1", + Kind: "ResolutionRequest", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "rr", + Namespace: "foo", + CreationTimestamp: metav1.Time{Time: time.Now()}, + Labels: map[string]string{ + resolutioncommon.LabelKeyResolverType: LabelValueFakeResolverType, + }, + }, + Spec: v1alpha1.ResolutionRequestSpec{ + Parameters: map[string]string{ + FakeParamName: "bar", + }, + }, + Status: v1alpha1.ResolutionRequestStatus{}, + }, + paramMap: map[string]*FakeResolvedResource{ + "bar": { + Content: "some content", + AnnotationMap: map[string]string{"foo": "bar"}, + }, + }, + expectedStatus: &v1alpha1.ResolutionRequestStatus{ + Status: duckv1.Status{ + Annotations: map[string]string{ + "foo": "bar", + }, + }, + ResolutionRequestStatusFields: v1alpha1.ResolutionRequestStatusFields{ + Data: base64.StdEncoding.Strict().EncodeToString([]byte("some content")), + }, + }, + }, { + name: "error resolving", + inputRequest: &v1alpha1.ResolutionRequest{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "resolution.tekton.dev/v1alpha1", + Kind: "ResolutionRequest", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "rr", + Namespace: "foo", + CreationTimestamp: metav1.Time{Time: time.Now()}, + Labels: map[string]string{ + resolutioncommon.LabelKeyResolverType: LabelValueFakeResolverType, + }, + }, + Spec: v1alpha1.ResolutionRequestSpec{ + Parameters: map[string]string{ + FakeParamName: "bar", + }, + }, + Status: v1alpha1.ResolutionRequestStatus{}, + }, + paramMap: map[string]*FakeResolvedResource{ + "bar": { + ErrorWith: "fake failure", + }, + }, + expectedErr: errors.New(`error getting "Fake" "foo/rr": fake failure`), + }, { + name: "timeout", + inputRequest: &v1alpha1.ResolutionRequest{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "resolution.tekton.dev/v1alpha1", + Kind: "ResolutionRequest", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "rr", + Namespace: "foo", + CreationTimestamp: metav1.Time{Time: time.Now().Add(-59 * time.Second)}, // 1 second before default timeout + Labels: map[string]string{ + resolutioncommon.LabelKeyResolverType: LabelValueFakeResolverType, + }, + }, + Spec: v1alpha1.ResolutionRequestSpec{ + Parameters: map[string]string{ + FakeParamName: "bar", + }, + }, + Status: v1alpha1.ResolutionRequestStatus{}, + }, + paramMap: map[string]*FakeResolvedResource{ + "bar": { + WaitFor: 1100 * time.Millisecond, + }, + }, + reconcilerTimeout: 1 * time.Second, + expectedErr: errors.New("context deadline exceeded"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + d := test.Data{ + ResolutionRequests: []*v1alpha1.ResolutionRequest{tc.inputRequest}, + } + + fakeResolver := &FakeResolver{ForParam: tc.paramMap} + if tc.reconcilerTimeout > 0 { + fakeResolver.Timeout = tc.reconcilerTimeout + } + + ctx, _ := ttesting.SetupFakeContext(t) + testAssets, cancel := getResolverFrameworkController(ctx, t, d, fakeResolver, setClockOnReconciler) + defer cancel() + + err := testAssets.Controller.Reconciler.Reconcile(testAssets.Ctx, getRequestName(tc.inputRequest)) + if tc.expectedErr != nil { + if err == nil { + t.Fatalf("expected to get error %v, but got nothing", tc.expectedErr) + } + if tc.expectedErr.Error() != err.Error() { + t.Fatalf("expected to get error %v, but got %v", tc.expectedErr, err) + } + } else { + if err != nil { + if ok, _ := controller.IsRequeueKey(err); !ok { + t.Fatalf("did not expect an error, but got %v", err) + } + } + + c := testAssets.Clients.ResolutionRequests.ResolutionV1alpha1() + reconciledRR, err := c.ResolutionRequests(tc.inputRequest.Namespace).Get(testAssets.Ctx, tc.inputRequest.Name, metav1.GetOptions{}) + if err != nil { + t.Fatalf("getting updated ResolutionRequest: %v", err) + } + if d := cmp.Diff(*tc.expectedStatus, reconciledRR.Status, ignoreLastTransitionTime); d != "" { + t.Errorf("ResolutionRequest status doesn't match %s", diff.PrintWantGot(d)) + + } + } + }) + } +} + +func getResolverFrameworkController(ctx context.Context, t *testing.T, d test.Data, resolver Resolver, modifiers ...ReconcilerModifier) (test.Assets, func()) { + t.Helper() + names.TestingSeed() + + ctx, cancel := context.WithCancel(ctx) + c, informers := test.SeedTestData(t, ctx, d) + configMapWatcher := cminformer.NewInformedWatcher(c.Kube, system.Namespace()) + ctl := NewController(ctx, resolver, modifiers...)(ctx, configMapWatcher) + if err := configMapWatcher.Start(ctx.Done()); err != nil { + t.Fatalf("error starting configmap watcher: %v", err) + } + + if la, ok := ctl.Reconciler.(pkgreconciler.LeaderAware); ok { + _ = la.Promote(pkgreconciler.UniversalBucket(), func(pkgreconciler.Bucket, types.NamespacedName) {}) + } + + return test.Assets{ + Logger: logging.FromContext(ctx), + Controller: ctl, + Clients: c, + Informers: informers, + Recorder: controller.GetEventRecorder(ctx).(*record.FakeRecorder), + Ctx: ctx, + }, cancel +} + +func getRequestName(rr *v1alpha1.ResolutionRequest) string { + return strings.Join([]string{rr.Namespace, rr.Name}, "/") +} + +func setClockOnReconciler(r *Reconciler) { + if r.Clock == nil { + r.Clock = testClock + } +} diff --git a/pkg/resolution/resolver/framework/testing/fakecontroller.go b/pkg/resolution/resolver/framework/testing/fakecontroller.go new file mode 100644 index 00000000000..8846b648bda --- /dev/null +++ b/pkg/resolution/resolver/framework/testing/fakecontroller.go @@ -0,0 +1,127 @@ +/* + Copyright 2022 The Tekton Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package testing + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1alpha1" + "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + "github.com/tektoncd/pipeline/test" + "github.com/tektoncd/pipeline/test/diff" + "github.com/tektoncd/pipeline/test/names" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/clock" + "k8s.io/client-go/tools/record" + "knative.dev/pkg/apis" + cminformer "knative.dev/pkg/configmap/informer" + "knative.dev/pkg/controller" + "knative.dev/pkg/logging" + pkgreconciler "knative.dev/pkg/reconciler" + "knative.dev/pkg/system" +) + +var ( + now = time.Date(2022, time.January, 1, 0, 0, 0, 0, time.UTC) + testClock = clock.NewFakePassiveClock(now) + ignoreLastTransitionTime = cmpopts.IgnoreFields(apis.Condition{}, "LastTransitionTime.Inner.Time") +) + +// RunResolverReconcileTest takes data to seed clients and informers, a Resolver, a ResolutionRequest, and the expected +// ResolutionRequestStatus and error, both of which can be nil. It instantiates a controller for that resolver and +// reconciles the given request. It then checks for the expected error, if any, and compares the resulting status with +// the expected status. +func RunResolverReconcileTest(ctx context.Context, t *testing.T, d test.Data, resolver framework.Resolver, request *v1alpha1.ResolutionRequest, + expectedStatus *v1alpha1.ResolutionRequestStatus, expectedErr error) { + t.Helper() + + testAssets, cancel := GetResolverFrameworkController(ctx, t, d, resolver, setClockOnReconciler) + defer cancel() + + err := testAssets.Controller.Reconciler.Reconcile(testAssets.Ctx, getRequestName(request)) + if expectedErr != nil { + if err == nil { + t.Fatalf("expected to get error %v, but got nothing", expectedErr) + } + if expectedErr.Error() != err.Error() { + t.Fatalf("expected to get error %v, but got %v", expectedErr, err) + } + } else if err != nil { + if ok, _ := controller.IsRequeueKey(err); !ok { + t.Fatalf("did not expect an error, but got %v", err) + } + } + + c := testAssets.Clients.ResolutionRequests.ResolutionV1alpha1() + reconciledRR, err := c.ResolutionRequests(request.Namespace).Get(testAssets.Ctx, request.Name, metav1.GetOptions{}) + if err != nil { + t.Fatalf("getting updated ResolutionRequest: %v", err) + } + if expectedStatus != nil { + if d := cmp.Diff(*expectedStatus, reconciledRR.Status, ignoreLastTransitionTime); d != "" { + t.Errorf("ResolutionRequest status doesn't match %s", diff.PrintWantGot(d)) + } + } +} + +// GetResolverFrameworkController returns an instance of the resolver framework controller/reconciler using the given resolver, +// seeded with d, where d represents the state of the system (existing resources) needed for the test. +func GetResolverFrameworkController(ctx context.Context, t *testing.T, d test.Data, resolver framework.Resolver, modifiers ...framework.ReconcilerModifier) (test.Assets, func()) { + t.Helper() + names.TestingSeed() + return initializeResolverFrameworkControllerAssets(ctx, t, d, resolver, modifiers...) +} + +func initializeResolverFrameworkControllerAssets(ctx context.Context, t *testing.T, d test.Data, resolver framework.Resolver, modifiers ...framework.ReconcilerModifier) (test.Assets, func()) { + t.Helper() + ctx, cancel := context.WithCancel(ctx) + c, informers := test.SeedTestData(t, ctx, d) + configMapWatcher := cminformer.NewInformedWatcher(c.Kube, system.Namespace()) + ctl := framework.NewController(ctx, resolver, modifiers...)(ctx, configMapWatcher) + if err := configMapWatcher.Start(ctx.Done()); err != nil { + t.Fatalf("error starting configmap watcher: %v", err) + } + + if la, ok := ctl.Reconciler.(pkgreconciler.LeaderAware); ok { + _ = la.Promote(pkgreconciler.UniversalBucket(), func(pkgreconciler.Bucket, types.NamespacedName) {}) + } + + return test.Assets{ + Logger: logging.FromContext(ctx), + Controller: ctl, + Clients: c, + Informers: informers, + Recorder: controller.GetEventRecorder(ctx).(*record.FakeRecorder), + Ctx: ctx, + }, cancel +} + +func getRequestName(rr *v1alpha1.ResolutionRequest) string { + return strings.Join([]string{rr.Namespace, rr.Name}, "/") +} + +func setClockOnReconciler(r *framework.Reconciler) { + if r.Clock == nil { + r.Clock = testClock + } +}