From fed06b0e94d2542bc4fcce7419578ae68cfecaa4 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 | 15 +- 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 | 13 +- .../cmd/demoresolver/main_test.go | 2 +- .../resolution}/resolutionrequest.go | 6 +- pkg/reconciler/pipelinerun/controller.go | 2 +- pkg/reconciler/pipelinerun/pipelinerun.go | 2 +- .../pipelinerun/resources/pipelineref.go | 12 +- .../pipelinerun/resources/pipelineref_test.go | 99 +- pkg/reconciler/taskrun/controller.go | 2 +- pkg/reconciler/taskrun/resources/taskref.go | 23 +- .../taskrun/resources/taskref_test.go | 65 +- pkg/reconciler/taskrun/resources/taskspec.go | 2 +- pkg/reconciler/taskrun/taskrun.go | 2 +- pkg/remote/resolution/error.go | 12 +- pkg/remote/resolution/resolver.go | 53 +- pkg/remote/resolution/resolver_test.go | 12 +- .../remote/resolution/request.go | 32 + .../remote/resolution/resolver.go | 91 ++ .../remote/resolution/resolver_test.go | 181 ++++ .../resolver/bundle/resolver.go | 78 ++ .../resolver/bundle/resolver_test.go | 610 ++++++++++++ .../resolver/cluster/resolver.go | 87 ++ .../resolver/cluster/resolver_test.go | 507 ++++++++++ .../resolver/framework/controller.go | 124 +++ .../resolver/framework/fakeresolver.go | 70 ++ .../resolver/framework/interface.go | 53 + .../resolver/framework/reconciler.go | 230 +++++ .../resolver/framework/reconciler_test.go | 290 ++++++ .../framework/testing/fakecontroller.go | 171 ++++ pkg/remoteresolution/resolver/git/resolver.go | 145 +++ .../resolver/git/resolver_test.go | 908 ++++++++++++++++++ .../refs/main/pipelines/example-pipeline.yaml | 10 + .../refs/main/tasks/example-task.yaml | 9 + .../other/pipelines/example-pipeline.yaml | 10 + .../resolver/http/resolver.go | 100 ++ .../resolver/http/resolver_test.go | 503 ++++++++++ pkg/remoteresolution/resolver/hub/resolver.go | 78 ++ .../resolver/hub/resolver_test.go | 312 ++++++ pkg/remoteresolution/resource/crd_resource.go | 92 ++ .../resource/crd_resource_test.go | 324 +++++++ pkg/remoteresolution/resource/request.go | 83 ++ pkg/remoteresolution/resource/request_test.go | 73 ++ pkg/remoteresolution/resource/resource.go | 37 + pkg/resolution/resolver/bundle/resolver.go | 45 +- .../resolver/bundle/resolver_test.go | 32 +- pkg/resolution/resolver/cluster/resolver.go | 118 ++- .../resolver/cluster/resolver_test.go | 20 +- .../resolver/framework/controller.go | 69 +- .../resolver/framework/fakeresolver.go | 26 +- pkg/resolution/resolver/git/config.go | 16 +- pkg/resolution/resolver/git/params.go | 40 +- pkg/resolution/resolver/git/resolver.go | 351 +++---- pkg/resolution/resolver/git/resolver_test.go | 148 +-- pkg/resolution/resolver/http/config.go | 4 +- pkg/resolution/resolver/http/params.go | 16 +- pkg/resolution/resolver/http/resolver.go | 86 +- pkg/resolution/resolver/http/resolver_test.go | 28 +- pkg/resolution/resolver/hub/resolver.go | 196 ++-- pkg/resolution/resolver/hub/resolver_test.go | 4 +- pkg/resolution/resource/crd_resource.go | 70 +- pkg/resolution/resource/crd_resource_test.go | 23 +- pkg/resolution/resource/name.go | 38 +- pkg/resolution/resource/request.go | 2 - test/remoteresolution/resolution.go | 166 ++++ test/{ => resolution}/resolution.go | 16 +- test/resolvers_test.go | 2 +- 70 files changed, 6814 insertions(+), 780 deletions(-) create mode 100644 docs/old-resolver-framework/how-to-write-a-resolver.md create mode 100644 docs/old-resolver-framework/resolver-reference.md rename pkg/{resolution/resolver/internal => internal/resolution}/resolutionrequest.go (91%) create mode 100644 pkg/remoteresolution/remote/resolution/request.go create mode 100644 pkg/remoteresolution/remote/resolution/resolver.go create mode 100644 pkg/remoteresolution/remote/resolution/resolver_test.go create mode 100644 pkg/remoteresolution/resolver/bundle/resolver.go create mode 100644 pkg/remoteresolution/resolver/bundle/resolver_test.go create mode 100644 pkg/remoteresolution/resolver/cluster/resolver.go create mode 100644 pkg/remoteresolution/resolver/cluster/resolver_test.go create mode 100644 pkg/remoteresolution/resolver/framework/controller.go create mode 100644 pkg/remoteresolution/resolver/framework/fakeresolver.go create mode 100644 pkg/remoteresolution/resolver/framework/interface.go create mode 100644 pkg/remoteresolution/resolver/framework/reconciler.go create mode 100644 pkg/remoteresolution/resolver/framework/reconciler_test.go create mode 100644 pkg/remoteresolution/resolver/framework/testing/fakecontroller.go create mode 100644 pkg/remoteresolution/resolver/git/resolver.go create mode 100644 pkg/remoteresolution/resolver/git/resolver_test.go create mode 100644 pkg/remoteresolution/resolver/git/testdata/test-org/test-repo/refs/main/pipelines/example-pipeline.yaml create mode 100644 pkg/remoteresolution/resolver/git/testdata/test-org/test-repo/refs/main/tasks/example-task.yaml create mode 100644 pkg/remoteresolution/resolver/git/testdata/test-org/test-repo/refs/other/pipelines/example-pipeline.yaml create mode 100644 pkg/remoteresolution/resolver/http/resolver.go create mode 100644 pkg/remoteresolution/resolver/http/resolver_test.go create mode 100644 pkg/remoteresolution/resolver/hub/resolver.go create mode 100644 pkg/remoteresolution/resolver/hub/resolver_test.go create mode 100644 pkg/remoteresolution/resource/crd_resource.go create mode 100644 pkg/remoteresolution/resource/crd_resource_test.go create mode 100644 pkg/remoteresolution/resource/request.go create mode 100644 pkg/remoteresolution/resource/request_test.go create mode 100644 pkg/remoteresolution/resource/resource.go create mode 100644 test/remoteresolution/resolution.go rename test/{ => resolution}/resolution.go (91%) diff --git a/cmd/resolvers/main.go b/cmd/resolvers/main.go index f66e9cd0a89..69677780805 100644 --- a/cmd/resolvers/main.go +++ b/cmd/resolvers/main.go @@ -21,12 +21,13 @@ import ( "strings" "github.com/tektoncd/pipeline/pkg/apis/resolution/v1alpha1" - "github.com/tektoncd/pipeline/pkg/resolution/resolver/bundle" - "github.com/tektoncd/pipeline/pkg/resolution/resolver/cluster" - "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" - "github.com/tektoncd/pipeline/pkg/resolution/resolver/git" - "github.com/tektoncd/pipeline/pkg/resolution/resolver/http" - "github.com/tektoncd/pipeline/pkg/resolution/resolver/hub" + "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/bundle" + "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/cluster" + "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/framework" + "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/git" + "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/http" + "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/hub" + hubv1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/hub" filteredinformerfactory "knative.dev/pkg/client/injection/kube/informers/factory/filtered" "knative.dev/pkg/injection/sharedmain" "knative.dev/pkg/signals" @@ -35,7 +36,7 @@ import ( func main() { ctx := filteredinformerfactory.WithSelectors(signals.NewContext(), v1alpha1.ManagedByLabelKey) tektonHubURL := buildHubURL(os.Getenv("TEKTON_HUB_API"), "") - artifactHubURL := buildHubURL(os.Getenv("ARTIFACT_HUB_API"), hub.DefaultArtifactHubURL) + artifactHubURL := buildHubURL(os.Getenv("ARTIFACT_HUB_API"), hubv1.DefaultArtifactHubURL) sharedmain.MainWithContext(ctx, "controller", framework.NewController(ctx, &git.Resolver{}), 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..20609c6c6e8 100644 --- a/docs/resolver-template/cmd/demoresolver/main.go +++ b/docs/resolver-template/cmd/demoresolver/main.go @@ -19,8 +19,9 @@ import ( pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/framework" "github.com/tektoncd/pipeline/pkg/resolution/common" - "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + frameworkV1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" filteredinformerfactory "knative.dev/pkg/client/injection/kube/informers/factory/filtered" "knative.dev/pkg/injection/sharedmain" ) @@ -51,16 +52,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) (frameworkV1.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..88a5f0c81c8 100644 --- a/docs/resolver-template/cmd/demoresolver/main_test.go +++ b/docs/resolver-template/cmd/demoresolver/main_test.go @@ -23,8 +23,8 @@ import ( "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" ttesting "github.com/tektoncd/pipeline/pkg/reconciler/testing" + frtesting "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/framework/testing" resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" - frtesting "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework/testing" "github.com/tektoncd/pipeline/test" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" _ "knative.dev/pkg/system/testing" diff --git a/pkg/resolution/resolver/internal/resolutionrequest.go b/pkg/internal/resolution/resolutionrequest.go similarity index 91% rename from pkg/resolution/resolver/internal/resolutionrequest.go rename to pkg/internal/resolution/resolutionrequest.go index be7f78f9a89..a7cf0909ae0 100644 --- a/pkg/resolution/resolver/internal/resolutionrequest.go +++ b/pkg/internal/resolution/resolutionrequest.go @@ -14,13 +14,13 @@ limitations under the License. */ -package internal +package resolution import ( "encoding/base64" "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" - resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + common "github.com/tektoncd/pipeline/pkg/resolution/common" corev1 "k8s.io/api/core/v1" "knative.dev/pkg/apis" duckv1 "knative.dev/pkg/apis/duck/v1" @@ -43,7 +43,7 @@ func CreateResolutionRequestFailureStatus() *v1beta1.ResolutionRequestStatus { Conditions: duckv1.Conditions{{ Type: apis.ConditionSucceeded, Status: corev1.ConditionFalse, - Reason: resolutioncommon.ReasonResolutionFailed, + Reason: common.ReasonResolutionFailed, }}, }, } diff --git a/pkg/reconciler/pipelinerun/controller.go b/pkg/reconciler/pipelinerun/controller.go index 5df3f698548..728cd752882 100644 --- a/pkg/reconciler/pipelinerun/controller.go +++ b/pkg/reconciler/pipelinerun/controller.go @@ -33,7 +33,7 @@ import ( "github.com/tektoncd/pipeline/pkg/pipelinerunmetrics" cloudeventclient "github.com/tektoncd/pipeline/pkg/reconciler/events/cloudevent" "github.com/tektoncd/pipeline/pkg/reconciler/volumeclaim" - resolution "github.com/tektoncd/pipeline/pkg/resolution/resource" + resolution "github.com/tektoncd/pipeline/pkg/remoteresolution/resource" "github.com/tektoncd/pipeline/pkg/tracing" "k8s.io/client-go/tools/cache" "k8s.io/utils/clock" diff --git a/pkg/reconciler/pipelinerun/pipelinerun.go b/pkg/reconciler/pipelinerun/pipelinerun.go index 8756c1282f4..943e56ae170 100644 --- a/pkg/reconciler/pipelinerun/pipelinerun.go +++ b/pkg/reconciler/pipelinerun/pipelinerun.go @@ -52,7 +52,7 @@ import ( tresources "github.com/tektoncd/pipeline/pkg/reconciler/taskrun/resources" "github.com/tektoncd/pipeline/pkg/reconciler/volumeclaim" "github.com/tektoncd/pipeline/pkg/remote" - resolution "github.com/tektoncd/pipeline/pkg/resolution/resource" + resolution "github.com/tektoncd/pipeline/pkg/remoteresolution/resource" "github.com/tektoncd/pipeline/pkg/substitution" "github.com/tektoncd/pipeline/pkg/trustedresources" "github.com/tektoncd/pipeline/pkg/workspace" diff --git a/pkg/reconciler/pipelinerun/resources/pipelineref.go b/pkg/reconciler/pipelinerun/resources/pipelineref.go index a6674f15483..ff9a1626d76 100644 --- a/pkg/reconciler/pipelinerun/resources/pipelineref.go +++ b/pkg/reconciler/pipelinerun/resources/pipelineref.go @@ -24,12 +24,13 @@ import ( v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + resolutionV1beta1 "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" clientset "github.com/tektoncd/pipeline/pkg/client/clientset/versioned" "github.com/tektoncd/pipeline/pkg/reconciler/apiserver" rprp "github.com/tektoncd/pipeline/pkg/reconciler/pipelinerun/pipelinespec" "github.com/tektoncd/pipeline/pkg/remote" - "github.com/tektoncd/pipeline/pkg/remote/resolution" - remoteresource "github.com/tektoncd/pipeline/pkg/resolution/resource" + "github.com/tektoncd/pipeline/pkg/remoteresolution/remote/resolution" + remoteresource "github.com/tektoncd/pipeline/pkg/remoteresolution/resource" "github.com/tektoncd/pipeline/pkg/trustedresources" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -70,7 +71,12 @@ 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{ + ResolutionSpec: &resolutionV1beta1.ResolutionRequestSpec{ + Params: replacedParams, + }, + } + resolver := resolution.NewResolver(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..c20337f4ff1 100644 --- a/pkg/reconciler/pipelinerun/resources/pipelineref_test.go +++ b/pkg/reconciler/pipelinerun/resources/pipelineref_test.go @@ -34,15 +34,18 @@ import ( v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + resolutionV1beta1 "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" "github.com/tektoncd/pipeline/pkg/client/clientset/versioned/fake" clientset "github.com/tektoncd/pipeline/pkg/client/clientset/versioned/fake" "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/remoteresolution/resource" "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/remoteresolution" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -344,8 +347,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.NewRequester(resolved, nil, resource.ResolverPayload{}) fn := resources.GetPipelineFunc(ctx, nil, clients, requester, &v1.PipelineRun{ ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, Spec: v1.PipelineRunSpec{ @@ -399,8 +402,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.NewRequester(resolved, nil, resource.ResolverPayload{}) fn := resources.GetPipelineFunc(ctx, nil, clients, requester, &v1.PipelineRun{ ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, Spec: v1.PipelineRunSpec{ @@ -452,16 +455,20 @@ 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.Requester{ ResolvedResource: resolved, - Params: v1.Params{{ - Name: "foo", - Value: *v1.NewStructuredValues("bar"), - }, { - Name: "bar", - Value: *v1.NewStructuredValues("test-pipeline"), - }}, + ResolverPayload: resource.ResolverPayload{ + ResolutionSpec: &resolutionV1beta1.ResolutionRequestSpec{ + 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 +545,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) + res := resolution.NewResolvedResource(resolvesTo, nil, nil, nil) + requester := resolution.NewRequester(res, nil, resource.ResolverPayload{}) fn := resources.GetPipelineFunc(ctx, nil, clients, requester, &v1.PipelineRun{ ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, Spec: v1.PipelineRunSpec{ @@ -577,8 +584,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.NewRequester(resolvedUnmatched, nil, resource.ResolverPayload{}) signedPipeline, err := test.GetSignedV1beta1Pipeline(unsignedPipeline, signer, "signed") if err != nil { @@ -600,8 +607,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.NewRequester(resolvedMatched, nil, resource.ResolverPayload{}) pipelineRef := &v1.PipelineRef{ Name: signedPipeline.Name, @@ -647,12 +654,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.NewRequester(resolvedUnsignedMatched, nil, resource.ResolverPayload{}) testcases := []struct { name string - requester *test.Requester + requester *resolution.Requester verificationNoMatchPolicy string pipelinerun v1.PipelineRun policies []*v1alpha1.VerificationPolicy @@ -778,8 +785,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.NewRequester(resolvedUnsigned, nil, resource.ResolverPayload{}) signedPipeline, err := test.GetSignedV1beta1Pipeline(unsignedPipeline, signer, "signed") if err != nil { @@ -797,8 +804,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.NewRequester(resolvedUnmatched, nil, resource.ResolverPayload{}) modifiedPipeline := signedPipeline.DeepCopy() modifiedPipeline.Annotations["random"] = "attack" @@ -806,14 +813,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.NewRequester(resolvedModified, nil, resource.ResolverPayload{}) pipelineRef := &v1.PipelineRef{ResolverRef: v1.ResolverRef{Resolver: "git"}} testcases := []struct { name string - requester *test.Requester + requester *resolution.Requester verificationNoMatchPolicy string expectedVerificationResult *trustedresources.VerificationResult }{ @@ -906,8 +913,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.NewRequester(resolvedUnmatched, nil, resource.ResolverPayload{}) signedPipeline, err := getSignedV1Pipeline(unsignedV1Pipeline, signer, "signed") if err != nil { @@ -935,8 +942,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.NewRequester(resolvedMatched, nil, resource.ResolverPayload{}) pipelineRef := &v1.PipelineRef{ Name: signedPipeline.Name, @@ -980,12 +987,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.NewRequester(resolvedUnsignedMatched, nil, resource.ResolverPayload{}) testcases := []struct { name string - requester *test.Requester + requester *resolution.Requester verificationNoMatchPolicy string pipelinerun v1.PipelineRun policies []*v1alpha1.VerificationPolicy @@ -1110,8 +1117,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.NewRequester(resolvedUnsigned, nil, resource.ResolverPayload{}) signedPipeline, err := getSignedV1Pipeline(unsignedV1Pipeline, signer, "signed") if err != nil { @@ -1129,8 +1136,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.NewRequester(resolvedUnmatched, nil, resource.ResolverPayload{}) modifiedPipeline := signedPipeline.DeepCopy() modifiedPipeline.Annotations["random"] = "attack" @@ -1138,14 +1145,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.NewRequester(resolvedModified, nil, resource.ResolverPayload{}) pipelineRef := &v1.PipelineRef{ResolverRef: v1.ResolverRef{Resolver: "git"}} testcases := []struct { name string - requester *test.Requester + requester *resolution.Requester verificationNoMatchPolicy string expectedVerificationResult *trustedresources.VerificationResult }{ @@ -1221,8 +1228,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.NewRequester(resolvedUnsigned, nil, resource.ResolverPayload{}) resolvedUnsigned.DataErr = errors.New("resolution error") prResolutionError := &v1.PipelineRun{ @@ -1242,7 +1249,7 @@ func TestGetPipelineFunc_GetFuncError(t *testing.T) { testcases := []struct { name string - requester *test.Requester + requester *resolution.Requester pipelinerun v1.PipelineRun expectedErr error }{ diff --git a/pkg/reconciler/taskrun/controller.go b/pkg/reconciler/taskrun/controller.go index 451c78f9795..024abd29373 100644 --- a/pkg/reconciler/taskrun/controller.go +++ b/pkg/reconciler/taskrun/controller.go @@ -31,7 +31,7 @@ import ( "github.com/tektoncd/pipeline/pkg/pod" cloudeventclient "github.com/tektoncd/pipeline/pkg/reconciler/events/cloudevent" "github.com/tektoncd/pipeline/pkg/reconciler/volumeclaim" - resolution "github.com/tektoncd/pipeline/pkg/resolution/resource" + resolution "github.com/tektoncd/pipeline/pkg/remoteresolution/resource" "github.com/tektoncd/pipeline/pkg/spire" "github.com/tektoncd/pipeline/pkg/taskrunmetrics" "github.com/tektoncd/pipeline/pkg/tracing" diff --git a/pkg/reconciler/taskrun/resources/taskref.go b/pkg/reconciler/taskrun/resources/taskref.go index 368ce8b78c7..5d5346c4791 100644 --- a/pkg/reconciler/taskrun/resources/taskref.go +++ b/pkg/reconciler/taskrun/resources/taskref.go @@ -25,11 +25,12 @@ import ( v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + resolutionV1beta1 "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" clientset "github.com/tektoncd/pipeline/pkg/client/clientset/versioned" "github.com/tektoncd/pipeline/pkg/reconciler/apiserver" "github.com/tektoncd/pipeline/pkg/remote" - "github.com/tektoncd/pipeline/pkg/remote/resolution" - remoteresource "github.com/tektoncd/pipeline/pkg/resolution/resource" + "github.com/tektoncd/pipeline/pkg/remoteresolution/remote/resolution" + remoteresource "github.com/tektoncd/pipeline/pkg/remoteresolution/resource" "github.com/tektoncd/pipeline/pkg/trustedresources" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -108,7 +109,14 @@ 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{ + Name: trName, + Namespace: namespace, + ResolutionSpec: &resolutionV1beta1.ResolutionRequestSpec{ + Params: replacedParams, + }, + } + resolver := resolution.NewResolver(requester, owner, string(tr.Resolver), resolverPayload) return resolveTask(ctx, resolver, name, namespace, kind, k8s, tekton, verificationPolicies) } @@ -136,7 +144,14 @@ 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{ + Name: trName, + Namespace: namespace, + ResolutionSpec: &resolutionV1beta1.ResolutionRequestSpec{ + Params: step.Ref.Params, + }, + } + resolver := resolution.NewResolver(requester, tr, string(step.Ref.Resolver), 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..ae05833873c 100644 --- a/pkg/reconciler/taskrun/resources/taskref_test.go +++ b/pkg/reconciler/taskrun/resources/taskref_test.go @@ -34,13 +34,16 @@ import ( v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + resolutionV1beta1 "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" "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/remoteresolution/resource" "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/remoteresolution" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -897,8 +900,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.NewRequester(resolved, nil, resource.ResolverPayload{}) tr := &v1.TaskRun{ ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, Spec: v1.TaskRunSpec{ @@ -958,8 +961,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) + res := resolution.NewResolvedResource(tc.resolvesTo, nil, nil, nil) + requester := resolution.NewRequester(res, nil, resource.ResolverPayload{}) tr := &v1.TaskRun{ ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, Spec: v1.TaskRunSpec{ @@ -1090,8 +1093,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.NewRequester(resolved, nil, resource.ResolverPayload{}) tr := &v1.TaskRun{ ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, Spec: v1.TaskRunSpec{ @@ -1157,8 +1160,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.NewRequester(resolved, nil, resource.ResolverPayload{}) tektonclient := fake.NewSimpleClientset() fn := resources.GetTaskFunc(ctx, nil, tektonclient, requester, &v1.TaskRun{ ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, @@ -1208,16 +1211,20 @@ 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.Requester{ ResolvedResource: resolved, - Params: v1.Params{{ - Name: "foo", - Value: *v1.NewStructuredValues("bar"), - }, { - Name: "bar", - Value: *v1.NewStructuredValues("test-task"), - }}, + ResolverPayload: resource.ResolverPayload{ + ResolutionSpec: &resolutionV1beta1.ResolutionRequestSpec{ + Params: v1.Params{{ + Name: "foo", + Value: *v1.NewStructuredValues("bar"), + }, { + Name: "bar", + Value: *v1.NewStructuredValues("test-task"), + }}, + }, + }, } tr := &v1.TaskRun{ ObjectMeta: metav1.ObjectMeta{ @@ -1293,8 +1300,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) + res := resolution.NewResolvedResource(resolvesTo, nil, nil, nil) + requester := resolution.NewRequester(res, nil, resource.ResolverPayload{}) tr := &v1.TaskRun{ ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, Spec: v1.TaskRunSpec{ @@ -1355,7 +1362,7 @@ func TestGetTaskFunc_V1beta1Task_VerifyNoError(t *testing.T) { testcases := []struct { name string - requester *test.Requester + requester *resolution.Requester verificationNoMatchPolicy string policies []*v1alpha1.VerificationPolicy expected runtime.Object @@ -1484,7 +1491,7 @@ func TestGetTaskFunc_V1beta1Task_VerifyError(t *testing.T) { testcases := []struct { name string - requester *test.Requester + requester *resolution.Requester verificationNoMatchPolicy string expected *v1.Task expectedErr error @@ -1621,7 +1628,7 @@ func TestGetTaskFunc_V1Task_VerifyNoError(t *testing.T) { testcases := []struct { name string - requester *test.Requester + requester *resolution.Requester verificationNoMatchPolicy string policies []*v1alpha1.VerificationPolicy expected runtime.Object @@ -1750,7 +1757,7 @@ func TestGetTaskFunc_V1Task_VerifyError(t *testing.T) { testcases := []struct { name string - requester *test.Requester + requester *resolution.Requester verificationNoMatchPolicy string expected *v1.Task expectedErr error @@ -1837,8 +1844,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.NewRequester(resolvedUnsigned, nil, resource.ResolverPayload{}) resolvedUnsigned.DataErr = errors.New("resolution error") trResolutionError := &v1.TaskRun{ @@ -1856,7 +1863,7 @@ func TestGetTaskFunc_GetFuncError(t *testing.T) { testcases := []struct { name string - requester *test.Requester + requester *resolution.Requester taskrun v1.TaskRun expectedErr error }{ @@ -1926,9 +1933,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.Requester { + resolved := resolution.NewResolvedResource(data, nil, source, nil) + requester := resolution.NewRequester(resolved, nil, resource.ResolverPayload{}) return requester } diff --git a/pkg/reconciler/taskrun/resources/taskspec.go b/pkg/reconciler/taskrun/resources/taskspec.go index 64d71df04e8..955154911c9 100644 --- a/pkg/reconciler/taskrun/resources/taskspec.go +++ b/pkg/reconciler/taskrun/resources/taskspec.go @@ -25,7 +25,7 @@ import ( "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1" clientset "github.com/tektoncd/pipeline/pkg/client/clientset/versioned" resolutionutil "github.com/tektoncd/pipeline/pkg/internal/resolution" - remoteresource "github.com/tektoncd/pipeline/pkg/resolution/resource" + remoteresource "github.com/tektoncd/pipeline/pkg/remoteresolution/resource" "github.com/tektoncd/pipeline/pkg/trustedresources" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" diff --git a/pkg/reconciler/taskrun/taskrun.go b/pkg/reconciler/taskrun/taskrun.go index 4259f1f9279..ebd744d05b7 100644 --- a/pkg/reconciler/taskrun/taskrun.go +++ b/pkg/reconciler/taskrun/taskrun.go @@ -47,7 +47,7 @@ import ( "github.com/tektoncd/pipeline/pkg/reconciler/taskrun/resources" "github.com/tektoncd/pipeline/pkg/reconciler/volumeclaim" "github.com/tektoncd/pipeline/pkg/remote" - resolution "github.com/tektoncd/pipeline/pkg/resolution/resource" + resolution "github.com/tektoncd/pipeline/pkg/remoteresolution/resource" "github.com/tektoncd/pipeline/pkg/spire" "github.com/tektoncd/pipeline/pkg/taskrunmetrics" _ "github.com/tektoncd/pipeline/pkg/taskrunmetrics/fake" // Make sure the taskrunmetrics are setup diff --git a/pkg/remote/resolution/error.go b/pkg/remote/resolution/error.go index 05022c5f8cb..9621060e074 100644 --- a/pkg/remote/resolution/error.go +++ b/pkg/remote/resolution/error.go @@ -36,7 +36,7 @@ var ( // InvalidRuntimeObjectError is returned when remote resolution // succeeded but the returned data is not a valid runtime.Object. type InvalidRuntimeObjectError struct { - original error + Original error } // ErrorInvalidRuntimeObject is an alias to InvalidRuntimeObjectError. @@ -51,12 +51,12 @@ var ( // Error returns the string representation of this error. func (e *InvalidRuntimeObjectError) Error() string { - return fmt.Sprintf("invalid runtime object: %v", e.original) + return fmt.Sprintf("invalid runtime object: %v", e.Original) } // Unwrap returns the underlying original error. func (e *InvalidRuntimeObjectError) Unwrap() error { - return e.original + return e.Original } // Is returns true if the given error coerces into an error of this type. @@ -68,7 +68,7 @@ func (e *InvalidRuntimeObjectError) Is(that error) bool { // attempting to access the resolved data failed. An example of this // type of error would be if a ResolutionRequest contained malformed base64. type DataAccessError struct { - original error + Original error } // ErrorAccessingData is an alias to DataAccessError @@ -83,12 +83,12 @@ var ( // Error returns the string representation of this error. func (e *DataAccessError) Error() string { - return fmt.Sprintf("error accessing data from remote resource: %v", e.original) + return fmt.Sprintf("error accessing data from remote resource: %v", e.Original) } // Unwrap returns the underlying original error. func (e *DataAccessError) Unwrap() error { - return e.original + return e.Original } // Is returns true if the given error coerces into an error of this type. diff --git a/pkg/remote/resolution/resolver.go b/pkg/remote/resolution/resolver.go index 772b39e416a..fb164924f03 100644 --- a/pkg/remote/resolution/resolver.go +++ b/pkg/remote/resolution/resolver.go @@ -63,6 +63,27 @@ func (resolver *Resolver) Get(ctx context.Context, _, _ string) (runtime.Object, return nil, nil, fmt.Errorf("error building request for remote resource: %w", err) } resolved, err := resolver.requester.Submit(ctx, resolverName, req) + return ResolvedRequest(resolved, err) +} + +// List implements remote.Resolver but is unused for remote resolution. +func (resolver *Resolver) List(_ context.Context) ([]remote.ResolvedObject, error) { + return nil, nil +} + +func buildRequest(resolverName string, owner kmeta.OwnerRefable, name string, namespace string, params v1.Params) (*resolutionRequest, error) { + name, namespace, err := remoteresource.GetNameAndNamespace(resolverName, owner, name, namespace, params) + if err != nil { + return nil, err + } + req := &resolutionRequest{ + Request: remoteresource.NewRequest(name, namespace, params), + owner: owner, + } + return req, nil +} + +func ResolvedRequest(resolved resolutioncommon.ResolvedResource, err error) (runtime.Object, *v1.RefSource, error) { switch { case errors.Is(err, resolutioncommon.ErrRequestInProgress): return nil, nil, remote.ErrRequestInProgress @@ -74,39 +95,11 @@ func (resolver *Resolver) Get(ctx context.Context, _, _ string) (runtime.Object, } data, err := resolved.Data() if err != nil { - return nil, nil, &DataAccessError{original: err} + 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 nil, nil, &InvalidRuntimeObjectError{Original: err} } return obj, resolved.RefSource(), nil } - -// List implements remote.Resolver but is unused for remote resolution. -func (resolver *Resolver) List(_ context.Context) ([]remote.ResolvedObject, error) { - return nil, nil -} - -func buildRequest(resolverName string, owner kmeta.OwnerRefable, name string, namespace string, params v1.Params) (*resolutionRequest, 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.GenerateDeterministicName(resolverName, remoteResourceBaseName, params) - if err != nil { - return nil, fmt.Errorf("error generating name for taskrun %s/%s: %w", namespace, name, err) - } - req := &resolutionRequest{ - Request: remoteresource.NewRequest(name, namespace, params), - owner: owner, - } - return req, nil -} 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/remoteresolution/remote/resolution/request.go b/pkg/remoteresolution/remote/resolution/request.go new file mode 100644 index 00000000000..c0c1654008f --- /dev/null +++ b/pkg/remoteresolution/remote/resolution/request.go @@ -0,0 +1,32 @@ +/* +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 ( + resolution "github.com/tektoncd/pipeline/pkg/remoteresolution/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/pkg/kmeta" +) + +var _ resolution.Request = &resolutionRequest{} +var _ resolution.OwnedRequest = &resolutionRequest{} + +type resolutionRequest struct { + resolution.Request + owner kmeta.OwnerRefable +} + +func (req *resolutionRequest) OwnerRef() metav1.OwnerReference { + return *kmeta.NewControllerRef(req.owner) +} diff --git a/pkg/remoteresolution/remote/resolution/resolver.go b/pkg/remoteresolution/remote/resolution/resolver.go new file mode 100644 index 00000000000..36dc06143e0 --- /dev/null +++ b/pkg/remoteresolution/remote/resolution/resolver.go @@ -0,0 +1,91 @@ +/* +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" + "fmt" + + v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + "github.com/tektoncd/pipeline/pkg/remote" + resolution "github.com/tektoncd/pipeline/pkg/remote/resolution" + remoteresource "github.com/tektoncd/pipeline/pkg/remoteresolution/resource" + remoteresourcev1 "github.com/tektoncd/pipeline/pkg/resolution/resource" + "k8s.io/apimachinery/pkg/runtime" + "knative.dev/pkg/kmeta" +) + +// Resolver 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 Resolver struct { + requester remoteresource.Requester + owner kmeta.OwnerRefable + resolverName string + resolverPayload remoteresource.ResolverPayload +} + +var _ remote.Resolver = &Resolver{} + +// NewResolver returns an implementation of remote.Resolver capable +// of performing asynchronous remote resolution. +func NewResolver(requester remoteresource.Requester, owner kmeta.OwnerRefable, resolverName string, resolverPayload remoteresource.ResolverPayload) remote.Resolver { + return &Resolver{ + requester: requester, + owner: owner, + resolverName: resolverName, + resolverPayload: resolverPayload, + } +} + +// Get implements remote.Resolver. +func (resolver *Resolver) Get(ctx context.Context, _, _ string) (runtime.Object, *v1.RefSource, error) { + resolverName := remoteresource.ResolverName(resolver.resolverName) + req, err := buildRequest(resolver.resolverName, resolver.owner, &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) + return resolution.ResolvedRequest(resolved, err) +} + +// List implements remote.Resolver but is unused for remote resolution. +func (resolver *Resolver) List(_ context.Context) ([]remote.ResolvedObject, error) { + return nil, nil +} + +func buildRequest(resolverName string, owner kmeta.OwnerRefable, resolverPayload *remoteresource.ResolverPayload) (*resolutionRequest, error) { + var name string + var namespace string + var params v1.Params + if resolverPayload != nil { + name = resolverPayload.Name + namespace = resolverPayload.Namespace + if resolverPayload.ResolutionSpec != nil { + params = resolverPayload.ResolutionSpec.Params + } + } + name, namespace, err := remoteresourcev1.GetNameAndNamespace(resolverName, owner, name, namespace, params) + if err != nil { + return nil, err + } + resolverPayload.Name = name + resolverPayload.Namespace = namespace + req := &resolutionRequest{ + Request: remoteresource.NewRequest(*resolverPayload), + owner: owner, + } + return req, nil +} diff --git a/pkg/remoteresolution/remote/resolution/resolver_test.go b/pkg/remoteresolution/remote/resolution/resolver_test.go new file mode 100644 index 00000000000..73d5b441ceb --- /dev/null +++ b/pkg/remoteresolution/remote/resolution/resolver_test.go @@ -0,0 +1,181 @@ +/* +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" + remoteV1 "github.com/tektoncd/pipeline/pkg/remote" + remoteresolution "github.com/tektoncd/pipeline/pkg/remote/resolution" + remoteresource "github.com/tektoncd/pipeline/pkg/remoteresolution/resource" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + remoteresourcev1 "github.com/tektoncd/pipeline/pkg/resolution/resource" + "github.com/tektoncd/pipeline/test/diff" + resolution "github.com/tektoncd/pipeline/test/remoteresolution" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/pkg/kmeta" +) + +var pipelineBytes = []byte(` +kind: Pipeline +apiVersion: tekton.dev/v1beta1 +metadata: + name: foo +spec: + tasks: + - name: task1 + taskSpec: + steps: + - name: step1 + image: ubuntu + script: | + echo "hello world!" +`) + +func TestGet_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.Requester{ + SubmitErr: nil, + ResolvedResource: resolved, + } + resolver := NewResolver(requester, owner, "git", remoteresource.ResolverPayload{}) + if _, _, err := resolver.Get(ctx, "foo", "bar"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + } +} + +func TestGet_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: remoteV1.ErrRequestInProgress, + resolvedResource: nil, + }, { + submitErr: nil, + expectedGetErr: remoteresolution.ErrNilResource, + resolvedResource: nil, + }, { + submitErr: genericError, + expectedGetErr: genericError, + resolvedResource: nil, + }, { + submitErr: nil, + expectedGetErr: &remoteresolution.InvalidRuntimeObjectError{}, + resolvedResource: notARuntimeObject, + }, { + submitErr: nil, + expectedGetErr: &remoteresolution.DataAccessError{}, + resolvedResource: invalidDataResource, + }} { + ctx := context.Background() + owner := &v1beta1.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "bar", + }, + } + requester := &resolution.Requester{ + SubmitErr: tc.submitErr, + ResolvedResource: tc.resolvedResource, + } + resolver := NewResolver(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", + }, + } + + rr := &remoteresource.ResolverPayload{Name: tc.targetName, Namespace: tc.targetNamespace} + req, err := buildRequest("git", owner, rr) + 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 := remoteresourcev1.GenerateDeterministicNameFromSpec("git", reqNameBase, rr.ResolutionSpec) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if expectedReqName != req.ResolverPayload().Name { + t.Errorf("expected request name %s, but was %s", expectedReqName, req.ResolverPayload().Name) + } + }) + } +} diff --git a/pkg/remoteresolution/resolver/bundle/resolver.go b/pkg/remoteresolution/resolver/bundle/resolver.go new file mode 100644 index 00000000000..914d5cbecae --- /dev/null +++ b/pkg/remoteresolution/resolver/bundle/resolver.go @@ -0,0 +1,78 @@ +/* + 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" + + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/framework" + "github.com/tektoncd/pipeline/pkg/resolution/common" + bundlev1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/bundle" + frameworkV1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + "k8s.io/client-go/kubernetes" + "knative.dev/pkg/client/injection/kube/client" +) + +const ( + // LabelValueBundleResolverType is the value to use for the + // resolution.tekton.dev/type label on resource requests + LabelValueBundleResolverType string = "bundles" + + // BundleResolverName is the name that the bundle resolver should be associated with. + BundleResolverName = "bundleresolver" +) + +var _ framework.Resolver = &Resolver{} + +// Resolver implements a framework.Resolver that can fetch files from OCI bundles. +type Resolver struct { + kubeClientSet kubernetes.Interface +} + +// Initialize sets up any dependencies needed by the Resolver. None atm. +func (r *Resolver) 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 *Resolver) GetName(context.Context) string { + return BundleResolverName +} + +// GetConfigName returns the name of the bundle resolver's configmap. +func (r *Resolver) GetConfigName(context.Context) string { + return bundlev1.ConfigMapName +} + +// 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: LabelValueBundleResolverType, + } +} + +// Validate ensures reqolution request spec from a request are as expected. +func (r *Resolver) Validate(ctx context.Context, req *v1beta1.ResolutionRequestSpec) error { + return bundlev1.ValidateParams(ctx, req.Params) +} + +// Resolve uses the given params to resolve the requested file or resource. +func (r *Resolver) Resolve(ctx context.Context, req *v1beta1.ResolutionRequestSpec) (frameworkV1.ResolvedResource, error) { + return bundlev1.ResolveRequest(ctx, r.kubeClientSet, req) +} diff --git a/pkg/remoteresolution/resolver/bundle/resolver_test.go b/pkg/remoteresolution/resolver/bundle/resolver_test.go new file mode 100644 index 00000000000..0d6ed50346c --- /dev/null +++ b/pkg/remoteresolution/resolver/bundle/resolver_test.go @@ -0,0 +1,610 @@ +/* + 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" + "time" + + "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" + "github.com/tektoncd/pipeline/pkg/internal/resolution" + ttesting "github.com/tektoncd/pipeline/pkg/reconciler/testing" + bundle "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/bundle" + frtesting "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/framework/testing" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + bundlev1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/bundle" + frtestingV1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework/testing" + "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" +) + +const ( + disabledError = "cannot handle resolution request, enable-bundles-resolver feature flag not true" +) + +func TestGetSelector(t *testing.T) { + resolver := bundle.Resolver{} + 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 TestValidate(t *testing.T) { + resolver := bundle.Resolver{} + + paramsWithTask := []pipelinev1.Param{{ + Name: bundlev1.ParamKind, + Value: *pipelinev1.NewStructuredValues("task"), + }, { + Name: bundlev1.ParamName, + Value: *pipelinev1.NewStructuredValues("foo"), + }, { + Name: bundlev1.ParamBundle, + Value: *pipelinev1.NewStructuredValues("bar"), + }, { + Name: bundlev1.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: bundlev1.ParamKind, + Value: *pipelinev1.NewStructuredValues("pipeline"), + }, { + Name: bundlev1.ParamName, + Value: *pipelinev1.NewStructuredValues("foo"), + }, { + Name: bundlev1.ParamBundle, + Value: *pipelinev1.NewStructuredValues("bar"), + }, { + Name: bundlev1.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 TestValidateDisabled(t *testing.T) { + resolver := bundle.Resolver{} + + var err error + + params := []pipelinev1.Param{{ + Name: bundlev1.ParamKind, + Value: *pipelinev1.NewStructuredValues("task"), + }, { + Name: bundlev1.ParamName, + Value: *pipelinev1.NewStructuredValues("foo"), + }, { + Name: bundlev1.ParamBundle, + Value: *pipelinev1.NewStructuredValues("bar"), + }, { + Name: bundlev1.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 TestValidateMissing(t *testing.T) { + resolver := bundle.Resolver{} + + var err error + + paramsMissingBundle := []pipelinev1.Param{{ + Name: bundlev1.ParamKind, + Value: *pipelinev1.NewStructuredValues("task"), + }, { + Name: bundlev1.ParamName, + Value: *pipelinev1.NewStructuredValues("foo"), + }, { + Name: bundlev1.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: bundlev1.ParamKind, + Value: *pipelinev1.NewStructuredValues("task"), + }, { + Name: bundlev1.ParamBundle, + Value: *pipelinev1.NewStructuredValues("bar"), + }, { + Name: bundlev1.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 TestResolveDisabled(t *testing.T) { + resolver := bundle.Resolver{} + + var err error + + params := []pipelinev1.Param{{ + Name: bundlev1.ParamKind, + Value: *pipelinev1.NewStructuredValues("task"), + }, { + Name: bundlev1.ParamName, + Value: *pipelinev1.NewStructuredValues("foo"), + }, { + Name: bundlev1.ParamBundle, + Value: *pipelinev1.NewStructuredValues("bar"), + }, { + Name: bundlev1.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_KeyChainError(t *testing.T) { + resolver := &bundle.Resolver{} + 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: bundlev1.ConfigMapName, + Namespace: resolverconfig.ResolversNamespace(system.Namespace()), + }, + Data: map[string]string{ + bundlev1.ConfigKind: "task", + }, + }}, + } + + testAssets, cancel := frtesting.GetResolverFrameworkController(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) + } +} + +type params struct { + secret string + bundle string + name string + kind string +} + +func TestResolve(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 <= bundlev1.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: resolution.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: resolution.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: resolution.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: resolution.CreateResolutionRequestStatusWithData(taskAsYAML), + }, { + name: "single pipeline", + args: ¶ms{ + bundle: testImages["single-pipeline"].uri + ":latest", + name: "example-pipeline", + kind: "pipeline", + }, + imageName: "single-pipeline", + expectedStatus: resolution.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: resolution.CreateResolutionRequestStatusWithData(pipelineAsYAML), + }, { + name: "too many objects in an image", + args: ¶ms{ + bundle: testImages["too-many-objs"].uri + ":latest", + name: "2-task", + kind: "task", + }, + expectedStatus: resolution.CreateResolutionRequestFailureStatus(), + expectedErrMessage: fmt.Sprintf("contained more than the maximum %d allow objects", bundlev1.MaximumBundleObjects), + }, { + name: "single task no version", + args: ¶ms{ + bundle: testImages["single-task-no-version"].uri + ":latest", + name: "foo", + kind: "task", + }, + expectedStatus: resolution.CreateResolutionRequestFailureStatus(), + expectedErrMessage: fmt.Sprintf("the layer 0 does not contain a %s annotation", bundlev1.BundleAnnotationAPIVersion), + }, { + name: "single task no kind", + args: ¶ms{ + bundle: testImages["single-task-no-kind"].uri + ":latest", + name: "foo", + kind: "task", + }, + expectedStatus: resolution.CreateResolutionRequestFailureStatus(), + expectedErrMessage: fmt.Sprintf("the layer 0 does not contain a %s annotation", bundlev1.BundleAnnotationKind), + }, { + name: "single task no name", + args: ¶ms{ + bundle: testImages["single-task-no-name"].uri + ":latest", + name: "foo", + kind: "task", + }, + expectedStatus: resolution.CreateResolutionRequestFailureStatus(), + expectedErrMessage: fmt.Sprintf("the layer 0 does not contain a %s annotation", bundlev1.BundleAnnotationName), + }, { + name: "single task kind incorrect form", + args: ¶ms{ + bundle: testImages["single-task-kind-incorrect-form"].uri + ":latest", + name: "foo", + kind: "task", + }, + expectedStatus: resolution.CreateResolutionRequestFailureStatus(), + expectedErrMessage: fmt.Sprintf("the layer 0 the annotation %s must be lowercased and singular, found %s", bundlev1.BundleAnnotationKind, "Task"), + }, + } + + resolver := &bundle.Resolver{} + confMap := map[string]string{ + bundlev1.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: bundlev1.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[bundlev1.ResolverAnnotationKind] = tc.kindInBundle + case tc.args.kind != "": + expectedStatus.Annotations[bundlev1.ResolverAnnotationKind] = tc.args.kind + default: + expectedStatus.Annotations[bundlev1.ResolverAnnotationKind] = "task" + } + + expectedStatus.Annotations[bundlev1.ResolverAnnotationName] = tc.args.name + expectedStatus.Annotations[bundlev1.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.RunResolverReconcileTest(ctx, t, d, resolver, request, expectedStatus, expectedError) + }) + } +} + +func createRequest(p *params) *v1beta1.ResolutionRequest { + rr := &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: bundle.LabelValueBundleResolverType, + }, + }, + Spec: v1beta1.ResolutionRequestSpec{ + Params: []pipelinev1.Param{{ + Name: bundlev1.ParamBundle, + Value: *pipelinev1.NewStructuredValues(p.bundle), + }, { + Name: bundlev1.ParamName, + Value: *pipelinev1.NewStructuredValues(p.name), + }, { + Name: bundlev1.ParamKind, + Value: *pipelinev1.NewStructuredValues(p.kind), + }, { + Name: bundlev1.ParamImagePullSecret, + Value: *pipelinev1.NewStructuredValues(p.secret), + }}, + }, + } + return rr +} + +func createError(image, msg string) error { + return &resolutioncommon.GetResourceError{ + ResolverName: bundle.BundleResolverName, + Key: "foo/rr", + Original: fmt.Errorf("invalid tekton bundle %s, error: %s", image, msg), + } +} + +func asIsMapper(obj runtime.Object) map[string]string { + annotations := map[string]string{} + if test.GetObjectName(obj) != "" { + annotations[bundlev1.BundleAnnotationName] = test.GetObjectName(obj) + } + + if obj.GetObjectKind().GroupVersionKind().Kind != "" { + annotations[bundlev1.BundleAnnotationKind] = obj.GetObjectKind().GroupVersionKind().Kind + } + if obj.GetObjectKind().GroupVersionKind().Version != "" { + annotations[bundlev1.BundleAnnotationAPIVersion] = obj.GetObjectKind().GroupVersionKind().Version + } + return annotations +} + +func resolverDisabledContext() context.Context { + return frtestingV1.ContextWithBundlesResolverDisabled(context.Background()) +} + +type imageRef struct { + // uri is the image repositry identifier i.e. "gcr.io/tekton-releases/catalog/upstream/golang-build" + uri string + // algo is the algorithm portion of a particular image digest i.e. "sha256". + algo string + // hex is hex encoded portion of a particular image digest i.e. "23293df97dc11957ec36a88c80101bb554039a76e8992a435112eea8283b30d4". + hex string +} + +// pushToRegistry pushes an image to the registry and returns an imageRef. +// It accepts a registry address, image name, the data and an ObjectAnnotationMapper +// to map an object to the annotations for it. +// NOTE: Every image pushed to the registry has a default tag named "latest". +func pushToRegistry(t *testing.T, registry, imageName string, data []runtime.Object, mapper test.ObjectAnnotationMapper) *imageRef { + t.Helper() + ref, err := test.CreateImageWithAnnotations(fmt.Sprintf("%s/%s:latest", registry, imageName), mapper, data...) + if err != nil { + t.Fatalf("couldn't push the image: %v", err) + } + + refSplit := strings.Split(ref, "@") + uri, digest := refSplit[0], refSplit[1] + digSplits := strings.Split(digest, ":") + algo, hex := digSplits[0], digSplits[1] + + return &imageRef{ + uri: uri, + algo: algo, + hex: hex, + } +} diff --git a/pkg/remoteresolution/resolver/cluster/resolver.go b/pkg/remoteresolution/resolver/cluster/resolver.go new file mode 100644 index 00000000000..d9575e9758b --- /dev/null +++ b/pkg/remoteresolution/resolver/cluster/resolver.go @@ -0,0 +1,87 @@ +/* +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" + + "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" + "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/framework" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + clusterv1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/cluster" + frameworkV1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" +) + +const ( + // LabelValueClusterResolverType is the value to use for the + // resolution.tekton.dev/type label on resource requests + LabelValueClusterResolverType string = "cluster" + + // ClusterResolverName is the name that the cluster resolver should be + // associated with + ClusterResolverName string = "Cluster" + + configMapName = "cluster-resolver-config" +) + +var _ framework.Resolver = &Resolver{} + +// ResolverV2 implements a framework.Resolver that can fetch resources from other namespaces. +type Resolver struct { + pipelineClientSet clientset.Interface +} + +// Initialize performs any setup required by the cluster resolver. +func (r *Resolver) 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 *Resolver) 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 *Resolver) 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 *Resolver) Validate(ctx context.Context, req *v1beta1.ResolutionRequestSpec) error { + return clusterv1.ValidateParams(ctx, req.Params) +} + +// Resolve performs the work of fetching a resource from a namespace with the given +// parameters. +func (r *Resolver) Resolve(ctx context.Context, req *v1beta1.ResolutionRequestSpec) (frameworkV1.ResolvedResource, error) { + return clusterv1.ResolveFromParams(ctx, req.Params, r.pipelineClientSet) +} + +var _ frameworkV1.ConfigWatcher = &Resolver{} + +// GetConfigName returns the name of the cluster resolver's configmap. +func (r *Resolver) GetConfigName(context.Context) string { + return configMapName +} diff --git a/pkg/remoteresolution/resolver/cluster/resolver_test.go b/pkg/remoteresolution/resolver/cluster/resolver_test.go new file mode 100644 index 00000000000..5f794637b4e --- /dev/null +++ b/pkg/remoteresolution/resolver/cluster/resolver_test.go @@ -0,0 +1,507 @@ +/* + 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" + "time" + + "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" + "github.com/tektoncd/pipeline/pkg/internal/resolution" + ttesting "github.com/tektoncd/pipeline/pkg/reconciler/testing" + cluster "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/cluster" + frtesting "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/framework/testing" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + clusterv1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/cluster" + frameworkV1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + frtestingV1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework/testing" + "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" +) + +const ( + disabledError = "cannot handle resolution request, enable-cluster-resolver feature flag not true" +) + +func TestGetSelector(t *testing.T) { + resolver := cluster.Resolver{} + 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 TestValidate(t *testing.T) { + resolver := cluster.Resolver{} + + params := []pipelinev1.Param{{ + Name: clusterv1.KindParam, + Value: *pipelinev1.NewStructuredValues("task"), + }, { + Name: clusterv1.NamespaceParam, + Value: *pipelinev1.NewStructuredValues("foo"), + }, { + Name: clusterv1.NameParam, + Value: *pipelinev1.NewStructuredValues("baz"), + }} + + ctx := frameworkV1.InjectResolverConfigToContext(context.Background(), map[string]string{ + clusterv1.AllowedNamespacesKey: "foo,bar", + clusterv1.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 TestValidateNotEnabled(t *testing.T) { + resolver := cluster.Resolver{} + + var err error + + params := []pipelinev1.Param{{ + Name: clusterv1.KindParam, + Value: *pipelinev1.NewStructuredValues("task"), + }, { + Name: clusterv1.NamespaceParam, + Value: *pipelinev1.NewStructuredValues("foo"), + }, { + Name: clusterv1.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 TestValidateFailure(t *testing.T) { + testCases := []struct { + name string + params map[string]string + conf map[string]string + expectedErr string + }{ + { + name: "missing kind", + params: map[string]string{ + clusterv1.NameParam: "foo", + clusterv1.NamespaceParam: "bar", + }, + expectedErr: "missing required cluster resolver params: kind", + }, { + name: "invalid kind", + params: map[string]string{ + clusterv1.KindParam: "banana", + clusterv1.NamespaceParam: "foo", + clusterv1.NameParam: "bar", + }, + expectedErr: "unknown or unsupported resource kind 'banana'", + }, { + name: "missing multiple", + params: map[string]string{ + clusterv1.KindParam: "task", + }, + expectedErr: "missing required cluster resolver params: name, namespace", + }, { + name: "not in allowed namespaces", + params: map[string]string{ + clusterv1.KindParam: "task", + clusterv1.NamespaceParam: "foo", + clusterv1.NameParam: "baz", + }, + conf: map[string]string{ + clusterv1.AllowedNamespacesKey: "abc,def", + }, + expectedErr: "access to specified namespace foo is not allowed", + }, { + name: "in blocked namespaces", + params: map[string]string{ + clusterv1.KindParam: "task", + clusterv1.NamespaceParam: "foo", + clusterv1.NameParam: "baz", + }, + conf: map[string]string{ + clusterv1.BlockedNamespacesKey: "foo,bar", + }, + expectedErr: "access to specified namespace foo is blocked", + }, + { + name: "blocked by star", + params: map[string]string{ + clusterv1.KindParam: "task", + clusterv1.NamespaceParam: "foo", + clusterv1.NameParam: "baz", + }, + conf: map[string]string{ + clusterv1.BlockedNamespacesKey: "*", + }, + expectedErr: "only explicit allowed access to namespaces is allowed", + }, + { + name: "blocked by star but allowed explicitly", + params: map[string]string{ + clusterv1.KindParam: "task", + clusterv1.NamespaceParam: "foo", + clusterv1.NameParam: "baz", + }, + conf: map[string]string{ + clusterv1.BlockedNamespacesKey: "*", + clusterv1.AllowedNamespacesKey: "foo", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resolver := &cluster.Resolver{} + + ctx := context.Background() + if len(tc.conf) > 0 { + ctx = frameworkV1.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 TestResolve(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: resolution.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: resolution.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: resolution.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{ + clusterv1.DefaultKindKey: "task", + clusterv1.DefaultNamespaceKey: defaultNS, + } + if tc.allowedNamespaces != "" { + confMap[clusterv1.AllowedNamespacesKey] = tc.allowedNamespaces + } + if tc.blockedNamespaces != "" { + confMap[clusterv1.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.Resolver{} + + 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[clusterv1.ResourceNameAnnotation] = reqParams[clusterv1.NameParam].StringVal + if reqParams[clusterv1.NamespaceParam].StringVal != "" { + expectedStatus.Annotations[clusterv1.ResourceNamespaceAnnotation] = reqParams[clusterv1.NamespaceParam].StringVal + } else { + expectedStatus.Annotations[clusterv1.ResourceNamespaceAnnotation] = defaultNS + } + } else { + expectedStatus.Status.Conditions[0].Message = tc.expectedErr.Error() + } + expectedStatus.Source = expectedStatus.RefSource + } + + frtesting.RunResolverReconcileTest(ctx, t, d, resolver, request, expectedStatus, tc.expectedErr) + }) + } +} + +func createRequest(kind, name, namespace string) *v1beta1.ResolutionRequest { + rr := &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: cluster.LabelValueClusterResolverType, + }, + }, + Spec: v1beta1.ResolutionRequestSpec{ + Params: []pipelinev1.Param{{ + Name: clusterv1.NameParam, + Value: *pipelinev1.NewStructuredValues(name), + }}, + }, + } + if kind != "" { + rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ + Name: clusterv1.KindParam, + Value: *pipelinev1.NewStructuredValues(kind), + }) + } + if namespace != "" { + rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ + Name: clusterv1.NamespaceParam, + Value: *pipelinev1.NewStructuredValues(namespace), + }) + } + + return rr +} + +func resolverDisabledContext() context.Context { + return frtestingV1.ContextWithClusterResolverDisabled(context.Background()) +} diff --git a/pkg/remoteresolution/resolver/framework/controller.go b/pkg/remoteresolution/resolver/framework/controller.go new file mode 100644 index 00000000000..4b10a314a71 --- /dev/null +++ b/pkg/remoteresolution/resolver/framework/controller.go @@ -0,0 +1,124 @@ +/* +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" + frameworkV1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + "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" +) + +// ReconcilerModifier is a func that can access and modify a reconciler +// in the moments before a resolver is started. It allows for +// things like injecting a test clock. +type ReconcilerModifier = func(reconciler *Reconciler) + +// NewController returns a knative controller for a Tekton Resolver. +// This sets up a lot of the boilerplate that individual resolvers +// shouldn't need to be concerned with since it's common to all of them. +func NewController(ctx context.Context, resolver Resolver, modifiers ...ReconcilerModifier) func(context.Context, configmap.Watcher) *controller.Impl { + if err := frameworkV1.ValidateResolver(ctx, resolver.GetSelector(ctx)); err != nil { + panic(err.Error()) + } + return func(ctx context.Context, cmw configmap.Watcher) *controller.Impl { + logger := logging.FromContext(ctx) + kubeclientset := kubeclient.Get(ctx) + rrclientset := rrclient.Get(ctx) + rrInformer := rrinformer.Get(ctx) + + if err := resolver.Initialize(ctx); err != nil { + panic(err.Error()) + } + + r := &Reconciler{ + LeaderAwareFuncs: frameworkV1.LeaderAwareFuncs(rrInformer.Lister()), + kubeClientSet: kubeclientset, + resolutionRequestLister: rrInformer.Lister(), + resolutionRequestClientSet: rrclientset, + resolver: resolver, + } + + watchConfigChanges(ctx, r, cmw) + + // TODO(sbwsg): Do better sanitize. + resolverName := resolver.GetName(ctx) + resolverName = strings.ReplaceAll(resolverName, "/", "") + resolverName = strings.ReplaceAll(resolverName, " ", "") + + applyModifiersAndDefaults(ctx, r, modifiers) + + impl := controller.NewContext(ctx, r, controller.ControllerOptions{ + WorkQueueName: "TektonResolverFramework." + resolverName, + Logger: logger, + }) + + _, err := rrInformer.Informer().AddEventHandler(cache.FilteringResourceEventHandler{ + FilterFunc: frameworkV1.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 + } +} + +// watchConfigChanges binds a framework.Resolver to updates on its +// configmap, using knative's configmap helpers. This is only done if +// the resolver implements the framework.ConfigWatcher interface. +func watchConfigChanges(ctx context.Context, reconciler *Reconciler, cmw configmap.Watcher) { + if configWatcher, ok := reconciler.resolver.(frameworkV1.ConfigWatcher); ok { + logger := logging.FromContext(ctx) + resolverConfigName := configWatcher.GetConfigName(ctx) + if resolverConfigName == "" { + panic("resolver returned empty config name") + } + reconciler.configStore = frameworkV1.NewConfigStore(resolverConfigName, logger) + reconciler.configStore.WatchConfigs(cmw) + } +} + +// applyModifiersAndDefaults applies the given modifiers to +// a reconciler and, after doing so, sets any default values for things +// that weren't set by a modifier. +func applyModifiersAndDefaults(ctx context.Context, r *Reconciler, modifiers []ReconcilerModifier) { + for _, mod := range modifiers { + mod(r) + } + + if r.Clock == nil { + r.Clock = clock.RealClock{} + } +} diff --git a/pkg/remoteresolution/resolver/framework/fakeresolver.go b/pkg/remoteresolution/resolver/framework/fakeresolver.go new file mode 100644 index 00000000000..9723de01ead --- /dev/null +++ b/pkg/remoteresolution/resolver/framework/fakeresolver.go @@ -0,0 +1,70 @@ +/* + Copyright 2022 The Tekton Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package framework + +import ( + "context" + "time" + + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + frameworkV1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" +) + +var _ Resolver = &FakeResolver{} + +// FakeResolver implements a framework.Resolver that can fetch pre-configured strings based on a parameter value, or return +// resolution attempts with a configured error. +type FakeResolver frameworkV1.FakeResolver + +// Initialize performs any setup required by the fake resolver. +func (r *FakeResolver) Initialize(ctx context.Context) error { + return frameworkV1.Initialize(r.ForParam) +} + +// GetName returns the string name that the fake resolver should be +// associated with. +func (r *FakeResolver) GetName(_ context.Context) string { + return frameworkV1.FakeResolverName +} + +// GetSelector returns the labels that resource requests are required to have for +// the fake resolver to process them. +func (r *FakeResolver) GetSelector(_ context.Context) map[string]string { + return map[string]string{ + resolutioncommon.LabelKeyResolverType: frameworkV1.LabelValueFakeResolverType, + } +} + +// Validate returns an error if the given parameter map is not +// valid for a resource request targeting the fake resolver. +func (r *FakeResolver) Validate(_ context.Context, req *v1beta1.ResolutionRequestSpec) error { + return frameworkV1.ValidateParams(req.Params) +} + +// Resolve performs the work of fetching a file from the fake resolver given a map of +// parameters. +func (r *FakeResolver) Resolve(_ context.Context, req *v1beta1.ResolutionRequestSpec) (frameworkV1.ResolvedResource, error) { + return frameworkV1.Resolve(req.Params, r.ForParam) +} + +var _ frameworkV1.TimedResolution = &FakeResolver{} + +// GetResolutionTimeout returns the configured timeout for the reconciler, or the default time.Duration if not configured. +func (r *FakeResolver) GetResolutionTimeout(ctx context.Context, defaultTimeout time.Duration) time.Duration { + return frameworkV1.GetResolutionTimeout(r.Timeout, defaultTimeout) +} diff --git a/pkg/remoteresolution/resolver/framework/interface.go b/pkg/remoteresolution/resolver/framework/interface.go new file mode 100644 index 00000000000..515b2f8d736 --- /dev/null +++ b/pkg/remoteresolution/resolver/framework/interface.go @@ -0,0 +1,53 @@ +/* +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" + frameworkV1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" +) + +// Resolver is the interface to implement for type-specific resource +// resolution. It fetches resources from a given type of remote location +// and returns their content along with any associated annotations. +type Resolver interface { + // Initialize is called at the moment the resolver controller is + // instantiated and is a good place to setup things like + // resource listers. + Initialize(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) (frameworkV1.ResolvedResource, error) +} diff --git a/pkg/remoteresolution/resolver/framework/reconciler.go b/pkg/remoteresolution/resolver/framework/reconciler.go new file mode 100644 index 00000000000..ce9f725bd4c --- /dev/null +++ b/pkg/remoteresolution/resolver/framework/reconciler.go @@ -0,0 +1,230 @@ +/* +Copyright 2022 The Tekton Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package framework + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "time" + + 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" + 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" + frameworkV1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + 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" +) + +// defaultMaximumResolutionDuration is the maximum amount of time +// resolution may take. + +// defaultMaximumResolutionDuration is the max time that a call to +// Resolve() may take. It can be overridden by a resolver implementing +// the framework.TimedResolution interface. +const defaultMaximumResolutionDuration = time.Minute + +// statusDataPatch is the json structure that will be PATCHed into +// a ResolutionRequest with its data and annotations once successfully +// resolved. +type statusDataPatch struct { + Annotations map[string]string `json:"annotations"` + Data string `json:"data"` + Source *pipelinev1beta1.ConfigSource `json:"source"` + RefSource *pipelinev1.RefSource `json:"refSource"` +} + +// Reconciler handles ResolutionRequest objects, performs functionality +// common to all resolvers and delegates resolver-specific actions +// to its embedded type-specific Resolver object. +type Reconciler struct { + // Implements reconciler.LeaderAware + reconciler.LeaderAwareFuncs + + // Clock is used by the reconciler to track the passage of time + // and can be overridden for tests. + Clock clock.PassiveClock + + resolver Resolver + kubeClientSet kubernetes.Interface + resolutionRequestLister rrv1beta1.ResolutionRequestLister + resolutionRequestClientSet rrclient.Interface + + configStore *frameworkV1.ConfigStore +} + +var _ reconciler.LeaderAware = &Reconciler{} + +// Reconcile receives the string key of a ResolutionRequest object, looks +// it up, checks it for common errors, and then delegates +// resolver-specific functionality to the reconciler's embedded +// type-specific resolver. Any errors that occur during validation or +// resolution are handled by updating or failing the ResolutionRequest. +func (r *Reconciler) Reconcile(ctx context.Context, key string) error { + namespace, name, err := cache.SplitMetaNamespaceKey(key) + if err != nil { + err = &resolutioncommon.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 *Reconciler) resolve(ctx context.Context, key string, rr *v1beta1.ResolutionRequest) error { + errChan := make(chan error) + resourceChan := make(chan frameworkV1.ResolvedResource) + + timeoutDuration := defaultMaximumResolutionDuration + if timed, ok := r.resolver.(frameworkV1.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 *Reconciler) 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 *Reconciler) 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 *Reconciler) writeResolvedData(ctx context.Context, rr *v1beta1.ResolutionRequest, resource frameworkV1.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/remoteresolution/resolver/framework/reconciler_test.go b/pkg/remoteresolution/resolver/framework/reconciler_test.go new file mode 100644 index 00000000000..904c37d0ac2 --- /dev/null +++ b/pkg/remoteresolution/resolver/framework/reconciler_test.go @@ -0,0 +1,290 @@ +/* + 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 ( + "context" + "encoding/base64" + "errors" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + 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" + "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/framework" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + frameworkv1 "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" + clock "k8s.io/utils/clock/testing" + "knative.dev/pkg/apis" + duckv1 "knative.dev/pkg/apis/duck/v1" + cminformer "knative.dev/pkg/configmap/informer" + "knative.dev/pkg/controller" + "knative.dev/pkg/logging" + pkgreconciler "knative.dev/pkg/reconciler" + "knative.dev/pkg/system" + _ "knative.dev/pkg/system/testing" // Setup system.Namespace() +) + +var ( + now = time.Date(2022, time.January, 1, 0, 0, 0, 0, time.UTC) + testClock = clock.NewFakePassiveClock(now) + ignoreLastTransitionTime = cmpopts.IgnoreFields(apis.Condition{}, "LastTransitionTime.Inner.Time") +) + +func TestReconcile(t *testing.T) { + testCases := []struct { + name string + inputRequest *v1beta1.ResolutionRequest + paramMap map[string]*frameworkv1.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: frameworkv1.LabelValueFakeResolverType, + }, + }, + Spec: v1beta1.ResolutionRequestSpec{ + Params: []pipelinev1.Param{{ + Name: frameworkv1.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: frameworkv1.LabelValueFakeResolverType, + }, + }, + Spec: v1beta1.ResolutionRequestSpec{ + Params: []pipelinev1.Param{{ + Name: frameworkv1.FakeParamName, + Value: *pipelinev1.NewStructuredValues("bar"), + }}, + }, + Status: v1beta1.ResolutionRequestStatus{}, + }, + paramMap: map[string]*frameworkv1.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: frameworkv1.LabelValueFakeResolverType, + }, + }, + Spec: v1beta1.ResolutionRequestSpec{ + Params: []pipelinev1.Param{{ + Name: frameworkv1.FakeParamName, + Value: *pipelinev1.NewStructuredValues("bar"), + }}, + }, + Status: v1beta1.ResolutionRequestStatus{}, + }, + paramMap: map[string]*frameworkv1.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: frameworkv1.LabelValueFakeResolverType, + }, + }, + Spec: v1beta1.ResolutionRequestSpec{ + Params: []pipelinev1.Param{{ + Name: frameworkv1.FakeParamName, + Value: *pipelinev1.NewStructuredValues("bar"), + }}, + }, + Status: v1beta1.ResolutionRequestStatus{}, + }, + paramMap: map[string]*frameworkv1.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.FakeResolver{ForParam: tc.paramMap} + if tc.reconcilerTimeout > 0 { + fakeResolver.Timeout = tc.reconcilerTimeout + } + + ctx, _ := ttesting.SetupFakeContext(t) + testAssets, cancel := getResolverFrameworkController(ctx, t, d, fakeResolver, setClockOnReconciler) + defer cancel() + + err := testAssets.Controller.Reconciler.Reconcile(testAssets.Ctx, getRequestName(tc.inputRequest)) + if tc.expectedErr != nil { + if err == nil { + t.Fatalf("expected to get error %v, but got nothing", tc.expectedErr) + } + if tc.expectedErr.Error() != err.Error() { + t.Fatalf("expected to get error %v, but got %v", tc.expectedErr, err) + } + } else { + if err != nil { + if ok, _ := controller.IsRequeueKey(err); !ok { + t.Fatalf("did not expect an error, but got %v", err) + } + } + + c := testAssets.Clients.ResolutionRequests.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)) + } + } + }) + } +} + +func getResolverFrameworkController(ctx context.Context, t *testing.T, d test.Data, resolver framework.Resolver, modifiers ...framework.ReconcilerModifier) (test.Assets, func()) { + t.Helper() + names.TestingSeed() + + ctx, cancel := context.WithCancel(ctx) + c, informers := test.SeedTestData(t, ctx, d) + configMapWatcher := cminformer.NewInformedWatcher(c.Kube, system.Namespace()) + ctl := framework.NewController(ctx, resolver, modifiers...)(ctx, configMapWatcher) + if err := configMapWatcher.Start(ctx.Done()); err != nil { + t.Fatalf("error starting configmap watcher: %v", err) + } + + if la, ok := ctl.Reconciler.(pkgreconciler.LeaderAware); ok { + _ = la.Promote(pkgreconciler.UniversalBucket(), func(pkgreconciler.Bucket, types.NamespacedName) {}) + } + + return test.Assets{ + Logger: logging.FromContext(ctx), + Controller: ctl, + Clients: c, + Informers: informers, + Recorder: controller.GetEventRecorder(ctx).(*record.FakeRecorder), + Ctx: ctx, + }, cancel +} + +func getRequestName(rr *v1beta1.ResolutionRequest) string { + return strings.Join([]string{rr.Namespace, rr.Name}, "/") +} + +func setClockOnReconciler(r *framework.Reconciler) { + if r.Clock == nil { + r.Clock = testClock + } +} diff --git a/pkg/remoteresolution/resolver/framework/testing/fakecontroller.go b/pkg/remoteresolution/resolver/framework/testing/fakecontroller.go new file mode 100644 index 00000000000..eefee4263da --- /dev/null +++ b/pkg/remoteresolution/resolver/framework/testing/fakecontroller.go @@ -0,0 +1,171 @@ +/* + 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" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + resolverconfig "github.com/tektoncd/pipeline/pkg/apis/config/resolver" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/framework" + "github.com/tektoncd/pipeline/test" + "github.com/tektoncd/pipeline/test/diff" + "github.com/tektoncd/pipeline/test/names" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + testclock "k8s.io/utils/clock/testing" + "knative.dev/pkg/apis" + cminformer "knative.dev/pkg/configmap/informer" + "knative.dev/pkg/controller" + "knative.dev/pkg/logging" + pkgreconciler "knative.dev/pkg/reconciler" + "knative.dev/pkg/system" +) + +var ( + now = time.Date(2022, time.January, 1, 0, 0, 0, 0, time.UTC) + testClock = testclock.NewFakePassiveClock(now) + ignoreLastTransitionTime = cmpopts.IgnoreFields(apis.Condition{}, "LastTransitionTime.Inner.Time") +) + +// ResolverReconcileTestModifier is a function thaat will be invoked after the test assets and controller have been created +type ResolverReconcileTestModifier = func(resolver framework.Resolver, 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 RunResolverReconcileTest(ctx context.Context, t *testing.T, d test.Data, resolver framework.Resolver, request *v1beta1.ResolutionRequest, + expectedStatus *v1beta1.ResolutionRequestStatus, expectedErr error, resolverModifiers ...ResolverReconcileTestModifier) { + t.Helper() + + testAssets, cancel := GetResolverFrameworkController(ctx, t, d, resolver, setClockOnReconciler) + 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 GetResolverFrameworkController(ctx context.Context, t *testing.T, d test.Data, resolver framework.Resolver, modifiers ...framework.ReconcilerModifier) (test.Assets, func()) { + t.Helper() + names.TestingSeed() + return initializeResolverFrameworkControllerAssets(ctx, t, d, resolver, modifiers...) +} + +func initializeResolverFrameworkControllerAssets(ctx context.Context, t *testing.T, d test.Data, resolver framework.Resolver, modifiers ...framework.ReconcilerModifier) (test.Assets, func()) { + t.Helper() + ctx, cancel := context.WithCancel(ctx) + ensureConfigurationConfigMapsExist(&d) + c, informers := test.SeedTestData(t, ctx, d) + configMapWatcher := cminformer.NewInformedWatcher(c.Kube, resolverconfig.ResolversNamespace(system.Namespace())) + ctl := framework.NewController(ctx, resolver, modifiers...)(ctx, configMapWatcher) + if err := configMapWatcher.Start(ctx.Done()); err != nil { + t.Fatalf("error starting configmap watcher: %v", err) + } + + if la, ok := ctl.Reconciler.(pkgreconciler.LeaderAware); ok { + _ = la.Promote(pkgreconciler.UniversalBucket(), func(pkgreconciler.Bucket, types.NamespacedName) {}) + } + + return test.Assets{ + Logger: logging.FromContext(ctx), + Controller: ctl, + Clients: c, + Informers: informers, + Recorder: controller.GetEventRecorder(ctx).(*record.FakeRecorder), + Ctx: ctx, + }, cancel +} + +func getRequestName(rr *v1beta1.ResolutionRequest) string { + return strings.Join([]string{rr.Namespace, rr.Name}, "/") +} + +func setClockOnReconciler(r *framework.Reconciler) { + if r.Clock == nil { + r.Clock = testClock + } +} + +func ensureConfigurationConfigMapsExist(d *test.Data) { + var featureFlagsExists bool + for _, cm := range d.ConfigMaps { + if cm.Name == resolverconfig.GetFeatureFlagsConfigName() { + featureFlagsExists = true + } + } + if !featureFlagsExists { + d.ConfigMaps = append(d.ConfigMaps, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: resolverconfig.GetFeatureFlagsConfigName(), + Namespace: resolverconfig.ResolversNamespace(system.Namespace()), + }, + Data: map[string]string{}, + }) + } +} diff --git a/pkg/remoteresolution/resolver/git/resolver.go b/pkg/remoteresolution/resolver/git/resolver.go new file mode 100644 index 00000000000..70a7daf2455 --- /dev/null +++ b/pkg/remoteresolution/resolver/git/resolver.go @@ -0,0 +1,145 @@ +/* +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" + "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/framework" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + frameworkV1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + gitv1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/git" + "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" +) + +const ( + disabledError = "cannot handle resolution request, enable-git-resolver feature flag not true" + + // labelValueGitResolverType is the value to use for the + // resolution.tekton.dev/type label on resource requests + labelValueGitResolverType string = "git" + + // gitResolverName is the name that the git resolver should be + // associated with + gitResolverName string = "Git" + + // ConfigMapName is the git resolver's config map + ConfigMapName = "git-resolver-config" + + // cacheSize is the size of the LRU secrets cache + cacheSize = 1024 + // ttl is the time to live for a cache entry + ttl = 5 * time.Minute +) + +var _ framework.Resolver = &Resolver{} + +// Resolver implements a framework.Resolver that can fetch files from git. +type Resolver 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 *Resolver) 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 *Resolver) 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 *Resolver) 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 *Resolver) Validate(ctx context.Context, req *v1beta1.ResolutionRequestSpec) error { + return gitv1.ValidateParams(ctx, req.Params) +} + +// Resolve performs the work of fetching a file from git given a map of +// parameters. +func (r *Resolver) Resolve(ctx context.Context, req *v1beta1.ResolutionRequestSpec) (frameworkV1.ResolvedResource, error) { + origParams := req.Params + + if gitv1.IsDisabled(ctx) { + return nil, errors.New(disabledError) + } + + params, err := gitv1.PopulateDefaultParams(ctx, origParams) + if err != nil { + return nil, err + } + + if params[gitv1.UrlParam] != "" { + return gitv1.ResolveAnonymousGit(ctx, params) + } + + return gitv1.ResolveAPIGit(ctx, params, r.kubeClient, r.logger, r.cache, r.ttl, r.clientFunc) +} + +var _ frameworkV1.ConfigWatcher = &Resolver{} + +// GetConfigName returns the name of the git resolver's configmap. +func (r *Resolver) GetConfigName(context.Context) string { + return ConfigMapName +} + +var _ frameworkV1.TimedResolution = &Resolver{} + +// 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 *Resolver) GetResolutionTimeout(ctx context.Context, defaultTimeout time.Duration) time.Duration { + conf := frameworkV1.GetResolverConfigFromContext(ctx) + if timeoutString, ok := conf[gitv1.DefaultTimeoutKey]; ok { + timeout, err := time.ParseDuration(timeoutString) + if err == nil { + return timeout + } + } + return defaultTimeout +} diff --git a/pkg/remoteresolution/resolver/git/resolver_test.go b/pkg/remoteresolution/resolver/git/resolver_test.go new file mode 100644 index 00000000000..470b28c1177 --- /dev/null +++ b/pkg/remoteresolution/resolver/git/resolver_test.go @@ -0,0 +1,908 @@ +/* +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" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "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" + "github.com/tektoncd/pipeline/pkg/internal/resolution" + ttesting "github.com/tektoncd/pipeline/pkg/reconciler/testing" + "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/framework" + frtesting "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/framework/testing" + common "github.com/tektoncd/pipeline/pkg/resolution/common" + frameworkv1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + frtestingv1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework/testing" + gitv1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/git" + "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 TestGetSelector(t *testing.T) { + resolver := Resolver{} + sel := resolver.GetSelector(context.Background()) + if typ, has := sel[common.LabelKeyResolverType]; !has { + t.Fatalf("unexpected selector: %v", sel) + } else if typ != labelValueGitResolverType { + t.Fatalf("unexpected type: %q", typ) + } +} + +func TestValidateParams(t *testing.T) { + tests := []struct { + name string + wantErr string + params map[string]string + }{ + { + name: "params with revision", + params: map[string]string{ + gitv1.UrlParam: "http://foo/bar/hello/moto", + gitv1.PathParam: "bar", + gitv1.RevisionParam: "baz", + }, + }, + { + name: "https url", + params: map[string]string{ + gitv1.UrlParam: "https://foo/bar/hello/moto", + gitv1.PathParam: "bar", + gitv1.RevisionParam: "baz", + }, + }, + { + name: "https url with username password", + params: map[string]string{ + gitv1.UrlParam: "https://user:pass@foo/bar/hello/moto", + gitv1.PathParam: "bar", + gitv1.RevisionParam: "baz", + }, + }, + { + name: "git server url", + params: map[string]string{ + gitv1.UrlParam: "git://repo/hello/moto", + gitv1.PathParam: "bar", + gitv1.RevisionParam: "baz", + }, + }, + { + name: "git url from a local repository", + params: map[string]string{ + gitv1.UrlParam: "/tmp/repo", + gitv1.PathParam: "bar", + gitv1.RevisionParam: "baz", + }, + }, + { + name: "git url from a git ssh repository", + params: map[string]string{ + gitv1.UrlParam: "git@host.com:foo/bar", + gitv1.PathParam: "bar", + gitv1.RevisionParam: "baz", + }, + }, + { + name: "bad url", + params: map[string]string{ + gitv1.UrlParam: "foo://bar", + gitv1.PathParam: "path", + gitv1.RevisionParam: "revision", + }, + wantErr: "invalid git repository url: foo://bar", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolver := Resolver{} + err := resolver.Validate(context.Background(), &v1beta1.ResolutionRequestSpec{Params: toParams(tt.params)}) + 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 TestValidateParamsNotEnabled(t *testing.T) { + resolver := Resolver{} + + var err error + + someParams := map[string]string{ + gitv1.PathParam: "bar", + gitv1.RevisionParam: "baz", + } + err = resolver.Validate(resolverDisabledContext(), &v1beta1.ResolutionRequestSpec{Params: toParams(someParams)}) + 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 TestValidateParams_Failure(t *testing.T) { + testCases := []struct { + name string + params map[string]string + expectedErr string + }{ + { + name: "missing multiple", + params: map[string]string{ + gitv1.OrgParam: "abcd1234", + gitv1.RepoParam: "foo", + }, + expectedErr: fmt.Sprintf("missing required git resolver params: %s, %s", gitv1.RevisionParam, gitv1.PathParam), + }, { + name: "no repo or url", + params: map[string]string{ + gitv1.RevisionParam: "abcd1234", + gitv1.PathParam: "/foo/bar", + }, + expectedErr: "must specify one of 'Url' or 'repo'", + }, { + name: "both repo and url", + params: map[string]string{ + gitv1.RevisionParam: "abcd1234", + gitv1.PathParam: "/foo/bar", + gitv1.UrlParam: "http://foo", + gitv1.RepoParam: "foo", + }, + expectedErr: "cannot specify both 'Url' and 'repo'", + }, { + name: "no org with repo", + params: map[string]string{ + gitv1.RevisionParam: "abcd1234", + gitv1.PathParam: "/foo/bar", + gitv1.RepoParam: "foo", + }, + expectedErr: "'org' is required when 'repo' is specified", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resolver := &Resolver{} + err := resolver.Validate(context.Background(), &v1beta1.ResolutionRequestSpec{Params: toParams(tc.params)}) + 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 TestGetResolutionTimeoutDefault(t *testing.T) { + resolver := Resolver{} + defaultTimeout := 30 * time.Minute + timeout := resolver.GetResolutionTimeout(context.Background(), defaultTimeout) + if timeout != defaultTimeout { + t.Fatalf("expected default timeout to be returned") + } +} + +func TestGetResolutionTimeoutCustom(t *testing.T) { + resolver := Resolver{} + defaultTimeout := 30 * time.Minute + configTimeout := 5 * time.Second + config := map[string]string{ + gitv1.DefaultTimeoutKey: configTimeout.String(), + } + ctx := frameworkv1.InjectResolverConfigToContext(context.Background(), config) + timeout := resolver.GetResolutionTimeout(ctx, defaultTimeout) + if timeout != configTimeout { + t.Fatalf("expected timeout from config to be returned") + } +} + +func TestResolveNotEnabled(t *testing.T) { + resolver := Resolver{} + + var err error + + someParams := map[string]string{ + gitv1.PathParam: "bar", + gitv1.RevisionParam: "baz", + } + _, err = resolver.Resolve(resolverDisabledContext(), &v1beta1.ResolutionRequestSpec{Params: toParams(someParams)}) + if err == nil { + t.Fatalf("expected disabled err") + } + if d := cmp.Diff(disabledError, err.Error()); d != "" { + t.Errorf("unexpected error: %s", diff.PrintWantGot(d)) + } +} + +type params struct { + url string + revision string + pathInRepo string + org string + repo string + token string + tokenKey string + namespace string + serverURL string + scmType string +} + +func TestResolve(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 := &Resolver{ + 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: resolution.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: resolution.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: resolution.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: resolution.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: resolution.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{ + gitv1.ServerURLKey: "fake", + gitv1.SCMTypeKey: "fake", + }, + apiToken: "some-token", + expectedCommitSHA: commitSHAsInSCMRepo[0], + expectedStatus: resolution.CreateResolutionRequestStatusWithData(mainTaskYAML), + }, { + name: "api: successful task", + args: ¶ms{ + revision: "main", + pathInRepo: "tasks/example-task.yaml", + org: testOrg, + repo: testRepo, + }, + config: map[string]string{ + gitv1.ServerURLKey: "fake", + gitv1.SCMTypeKey: "fake", + gitv1.APISecretNameKey: "token-secret", + gitv1.APISecretKeyKey: "token", + gitv1.APISecretNamespaceKey: system.Namespace(), + }, + apiToken: "some-token", + expectedCommitSHA: commitSHAsInSCMRepo[0], + expectedStatus: resolution.CreateResolutionRequestStatusWithData(mainTaskYAML), + }, { + name: "api: successful pipeline", + args: ¶ms{ + revision: "main", + pathInRepo: "pipelines/example-pipeline.yaml", + org: testOrg, + repo: testRepo, + }, + config: map[string]string{ + gitv1.ServerURLKey: "fake", + gitv1.SCMTypeKey: "fake", + gitv1.APISecretNameKey: "token-secret", + gitv1.APISecretKeyKey: "token", + gitv1.APISecretNamespaceKey: system.Namespace(), + }, + apiToken: "some-token", + expectedCommitSHA: commitSHAsInSCMRepo[0], + expectedStatus: resolution.CreateResolutionRequestStatusWithData(mainPipelineYAML), + }, { + name: "api: successful pipeline with default revision", + args: ¶ms{ + pathInRepo: "pipelines/example-pipeline.yaml", + org: testOrg, + repo: testRepo, + }, + config: map[string]string{ + gitv1.ServerURLKey: "fake", + gitv1.SCMTypeKey: "fake", + gitv1.APISecretNameKey: "token-secret", + gitv1.APISecretKeyKey: "token", + gitv1.APISecretNamespaceKey: system.Namespace(), + gitv1.DefaultRevisionKey: "other", + }, + apiToken: "some-token", + expectedCommitSHA: commitSHAsInSCMRepo[1], + expectedStatus: resolution.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{ + gitv1.ServerURLKey: "notsofake", + gitv1.SCMTypeKey: "definitivelynotafake", + }, + apiToken: "some-token", + expectedCommitSHA: commitSHAsInSCMRepo[0], + expectedStatus: resolution.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{ + gitv1.ServerURLKey: "fake", + gitv1.SCMTypeKey: "fake", + gitv1.APISecretNameKey: "token-secret", + gitv1.APISecretKeyKey: "token", + gitv1.APISecretNamespaceKey: system.Namespace(), + }, + apiToken: "some-token", + expectedStatus: resolution.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{ + gitv1.ServerURLKey: "fake", + gitv1.SCMTypeKey: "fake", + gitv1.APISecretNameKey: "token-secret", + gitv1.APISecretKeyKey: "token", + gitv1.APISecretNamespaceKey: system.Namespace(), + }, + expectedStatus: resolution.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{ + gitv1.ServerURLKey: "fake", + gitv1.SCMTypeKey: "fake", + gitv1.APISecretKeyKey: "token", + gitv1.APISecretNamespaceKey: system.Namespace(), + }, + apiToken: "some-token", + expectedStatus: resolution.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{ + gitv1.ServerURLKey: "fake", + gitv1.SCMTypeKey: "fake", + gitv1.APISecretNameKey: "token-secret", + gitv1.APISecretNamespaceKey: system.Namespace(), + }, + apiToken: "some-token", + expectedStatus: resolution.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{ + gitv1.APISecretNameKey: "token-secret", + gitv1.APISecretKeyKey: "token", + gitv1.APISecretNamespaceKey: system.Namespace(), + }, + apiToken: "some-token", + expectedStatus: resolution.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[gitv1.DefaultTimeoutKey] = "1m" + if cfg[gitv1.DefaultRevisionKey] == "" { + cfg[gitv1.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[common.AnnotationKeyContentType] = "application/x-yaml" + expectedStatus.Annotations[gitv1.AnnotationKeyRevision] = tc.expectedCommitSHA + expectedStatus.Annotations[gitv1.AnnotationKeyPath] = tc.args.pathInRepo + + if tc.args.url != "" { + expectedStatus.Annotations[gitv1.AnnotationKeyURL] = anonFakeRepoURL + } else { + expectedStatus.Annotations[gitv1.AnnotationKeyOrg] = testOrg + expectedStatus.Annotations[gitv1.AnnotationKeyRepo] = testRepo + expectedStatus.Annotations[gitv1.AnnotationKeyURL] = scmFakeRepoURL + } + + // status.refSource + expectedStatus.RefSource = &pipelinev1.RefSource{ + URI: "git+" + expectedStatus.Annotations[gitv1.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.RunResolverReconcileTest(ctx, t, d, resolver, request, expectedStatus, tc.expectedErr, func(resolver framework.Resolver, testAssets test.Assets) { + var secretName, secretNameKey, secretNamespace string + if tc.config[gitv1.APISecretNameKey] != "" && tc.config[gitv1.APISecretNamespaceKey] != "" && tc.config[gitv1.APISecretKeyKey] != "" && tc.apiToken != "" { + secretName, secretNameKey, secretNamespace = tc.config[gitv1.APISecretNameKey], tc.config[gitv1.APISecretKeyKey], tc.config[gitv1.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) + } + }) + }) + } +} + +// createTestRepo is used to instantiate a local test repository with the desired commits. +func createTestRepo(t *testing.T, commits []commitForRepo) (string, []string) { + t.Helper() + commitSHAs := []string{} + + t.Helper() + tempDir := t.TempDir() + + repo, err := git.PlainInit(tempDir, false) + + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("getting test worktree: %v", err) + } + if worktree == nil { + t.Fatal("test worktree not created") + } + + startingHash := writeAndCommitToTestRepo(t, worktree, tempDir, "", "README", []byte("This is a test")) + + hashesByBranch := make(map[string][]string) + + // Iterate over the commits and add them. + for _, cmt := range commits { + branch := cmt.Branch + if branch == "" { + branch = plumbing.Master.Short() + } + + // If we're given a revision, check out that revision. + coOpts := &git.CheckoutOptions{ + Branch: plumbing.NewBranchReferenceName(branch), + } + + if _, ok := hashesByBranch[branch]; !ok && branch != plumbing.Master.Short() { + coOpts.Hash = plumbing.NewHash(startingHash.String()) + coOpts.Create = true + } + + if err := worktree.Checkout(coOpts); err != nil { + t.Fatalf("couldn't do checkout of %s: %v", branch, err) + } + + hash := writeAndCommitToTestRepo(t, worktree, tempDir, cmt.Dir, cmt.Filename, []byte(cmt.Content)) + commitSHAs = append(commitSHAs, hash.String()) + + if _, ok := hashesByBranch[branch]; !ok { + hashesByBranch[branch] = []string{hash.String()} + } else { + hashesByBranch[branch] = append(hashesByBranch[branch], hash.String()) + } + + if cmt.Tag != "" { + _, err = repo.CreateTag(cmt.Tag, hash, &git.CreateTagOptions{ + Message: cmt.Tag, + Tagger: &object.Signature{ + Name: "Someone", + Email: "someone@example.com", + When: time.Now(), + }, + }) + } + if err != nil { + t.Fatalf("couldn't add tag for %s: %v", cmt.Tag, err) + } + } + + return tempDir, commitSHAs +} + +// commitForRepo provides the directory, filename, content and revision for a test commit. +type commitForRepo struct { + Dir string + Filename string + Content string + Branch string + Tag string +} + +func writeAndCommitToTestRepo(t *testing.T, worktree *git.Worktree, repoDir string, subPath string, filename string, content []byte) plumbing.Hash { + t.Helper() + + targetDir := repoDir + if subPath != "" { + targetDir = filepath.Join(targetDir, subPath) + fi, err := os.Stat(targetDir) + if os.IsNotExist(err) { + if err := os.MkdirAll(targetDir, 0o700); err != nil { + t.Fatalf("couldn't create directory %s in worktree: %v", targetDir, err) + } + } else if err != nil { + t.Fatalf("checking if directory %s in worktree exists: %v", targetDir, err) + } + if fi != nil && !fi.IsDir() { + t.Fatalf("%s already exists but is not a directory", targetDir) + } + } + + outfile := filepath.Join(targetDir, filename) + if err := os.WriteFile(outfile, content, 0o600); err != nil { + t.Fatalf("couldn't write content to file %s: %v", outfile, err) + } + + _, err := worktree.Add(filepath.Join(subPath, filename)) + if err != nil { + t.Fatalf("couldn't add file %s to git: %v", outfile, err) + } + + hash, err := worktree.Commit("adding file for test", &git.CommitOptions{ + Author: &object.Signature{ + Name: "Someone", + Email: "someone@example.com", + When: time.Now(), + }, + }) + if err != nil { + t.Fatalf("couldn't perform commit for test: %v", err) + } + + return hash +} + +// withTemporaryGitConfig resets the .gitconfig for the duration of the test. +func withTemporaryGitConfig(t *testing.T) { + t.Helper() + gitConfigDir := t.TempDir() + key := "GIT_CONFIG_GLOBAL" + t.Setenv(key, filepath.Join(gitConfigDir, "config")) +} + +func createRequest(args *params) *v1beta1.ResolutionRequest { + rr := &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{ + common.LabelKeyResolverType: labelValueGitResolverType, + }, + }, + Spec: v1beta1.ResolutionRequestSpec{ + Params: []pipelinev1.Param{{ + Name: gitv1.PathParam, + Value: *pipelinev1.NewStructuredValues(args.pathInRepo), + }}, + }, + } + + if args.revision != "" { + rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ + Name: gitv1.RevisionParam, + Value: *pipelinev1.NewStructuredValues(args.revision), + }) + } + + if args.serverURL != "" { + rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ + Name: gitv1.ServerURLParam, + Value: *pipelinev1.NewStructuredValues(args.serverURL), + }) + } + if args.scmType != "" { + rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ + Name: gitv1.ScmTypeParam, + Value: *pipelinev1.NewStructuredValues(args.scmType), + }) + } + + if args.url != "" { + rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ + Name: gitv1.UrlParam, + Value: *pipelinev1.NewStructuredValues(args.url), + }) + } else { + rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ + Name: gitv1.RepoParam, + Value: *pipelinev1.NewStructuredValues(args.repo), + }) + rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ + Name: gitv1.OrgParam, + Value: *pipelinev1.NewStructuredValues(args.org), + }) + if args.token != "" { + rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ + Name: gitv1.TokenParam, + Value: *pipelinev1.NewStructuredValues(args.token), + }) + rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ + Name: gitv1.TokenKeyParam, + Value: *pipelinev1.NewStructuredValues(args.tokenKey), + }) + } + } + + return rr +} + +func resolverDisabledContext() context.Context { + return frtestingv1.ContextWithGitResolverDisabled(context.Background()) +} + +func createError(msg string) error { + return &common.GetResourceError{ + ResolverName: gitResolverName, + Key: "foo/rr", + Original: errors.New(msg), + } +} + +func toParams(m map[string]string) []pipelinev1.Param { + var params []pipelinev1.Param + + for k, v := range m { + params = append(params, pipelinev1.Param{ + Name: k, + Value: *pipelinev1.NewStructuredValues(v), + }) + } + + return params +} diff --git a/pkg/remoteresolution/resolver/git/testdata/test-org/test-repo/refs/main/pipelines/example-pipeline.yaml b/pkg/remoteresolution/resolver/git/testdata/test-org/test-repo/refs/main/pipelines/example-pipeline.yaml new file mode 100644 index 00000000000..cc697dd2e91 --- /dev/null +++ b/pkg/remoteresolution/resolver/git/testdata/test-org/test-repo/refs/main/pipelines/example-pipeline.yaml @@ -0,0 +1,10 @@ +apiVersion: tekton.dev/v1beta1 +kind: Pipeline +metadata: + name: example-pipeline +spec: + tasks: + - name: some-pipeline-task + taskRef: + kind: Task + name: some-task diff --git a/pkg/remoteresolution/resolver/git/testdata/test-org/test-repo/refs/main/tasks/example-task.yaml b/pkg/remoteresolution/resolver/git/testdata/test-org/test-repo/refs/main/tasks/example-task.yaml new file mode 100644 index 00000000000..97ad418341e --- /dev/null +++ b/pkg/remoteresolution/resolver/git/testdata/test-org/test-repo/refs/main/tasks/example-task.yaml @@ -0,0 +1,9 @@ +apiVersion: tekton.dev/v1beta1 +kind: Task +metadata: + name: example-task +spec: + steps: + - command: ['something'] + image: some-image + name: some-step diff --git a/pkg/remoteresolution/resolver/git/testdata/test-org/test-repo/refs/other/pipelines/example-pipeline.yaml b/pkg/remoteresolution/resolver/git/testdata/test-org/test-repo/refs/other/pipelines/example-pipeline.yaml new file mode 100644 index 00000000000..cfec4bb5618 --- /dev/null +++ b/pkg/remoteresolution/resolver/git/testdata/test-org/test-repo/refs/other/pipelines/example-pipeline.yaml @@ -0,0 +1,10 @@ +apiVersion: tekton.dev/v1beta1 +kind: Pipeline +metadata: + name: example-pipeline +spec: + tasks: + - name: some-pipeline-task + taskRef: + kind: Task + name: some-other-task diff --git a/pkg/remoteresolution/resolver/http/resolver.go b/pkg/remoteresolution/resolver/http/resolver.go new file mode 100644 index 00000000000..bcb74ce8030 --- /dev/null +++ b/pkg/remoteresolution/resolver/http/resolver.go @@ -0,0 +1,100 @@ +/* +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/remoteresolution/resolver/framework" + "github.com/tektoncd/pipeline/pkg/resolution/common" + frameworkV1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + httpv1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/http" + "go.uber.org/zap" + "k8s.io/client-go/kubernetes" + kubeclient "knative.dev/pkg/client/injection/kube/client" + "knative.dev/pkg/logging" +) + +const ( + // LabelValueHttpResolverType is the value to use for the + // resolution.tekton.dev/type label on resource requests + LabelValueHttpResolverType string = "http" + + disabledError = "cannot handle resolution request, enable-http-resolver feature flag not true" + + // httpResolverName The name of the resolver + httpResolverName = "Http" + + // configMapName is the http resolver's config map + configMapName = "http-resolver-config" + + // default Timeout value when fetching http resources in seconds + defaultHttpTimeoutValue = "1m" + + // default key in the HTTP password secret + defaultBasicAuthSecretKey = "password" +) + +var _ framework.Resolver = &Resolver{} + +// Resolver implements a framework.Resolver that can fetch files from an HTTP URL +type Resolver struct { + kubeClient kubernetes.Interface + logger *zap.SugaredLogger +} + +func (r *Resolver) 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 *Resolver) GetName(context.Context) string { + return httpResolverName +} + +// GetConfigName returns the name of the http resolver's configmap. +func (r *Resolver) GetConfigName(context.Context) string { + return configMapName +} + +// 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: LabelValueHttpResolverType, + } +} + +// Validate ensures parameters from a request are as expected. +func (r *Resolver) Validate(ctx context.Context, req *v1beta1.ResolutionRequestSpec) error { + return httpv1.ValidateParams(ctx, req.Params) +} + +// Resolve uses the given params to resolve the requested file or resource. +func (r *Resolver) Resolve(ctx context.Context, req *v1beta1.ResolutionRequestSpec) (frameworkV1.ResolvedResource, error) { + oParams := req.Params + if httpv1.IsDisabled(ctx) { + return nil, errors.New(disabledError) + } + + params, err := httpv1.PopulateDefaultParams(ctx, oParams) + if err != nil { + return nil, err + } + + return httpv1.FetchHttpResource(ctx, params, r.kubeClient, r.logger) +} diff --git a/pkg/remoteresolution/resolver/http/resolver_test.go b/pkg/remoteresolution/resolver/http/resolver_test.go new file mode 100644 index 00000000000..31947edc447 --- /dev/null +++ b/pkg/remoteresolution/resolver/http/resolver_test.go @@ -0,0 +1,503 @@ +/* +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" + "time" + + "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" + "github.com/tektoncd/pipeline/pkg/internal/resolution" + ttesting "github.com/tektoncd/pipeline/pkg/reconciler/testing" + "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/framework" + frtesting "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/framework/testing" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + frameworkV1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + frtestingV1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework/testing" + httpv1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/http" + "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" +) + +type params struct { + url string + authUsername string + authSecret string + authSecretKey string + authSecretContent string +} + +const sampleTask = `--- +kind: Task +apiVersion: tekton.dev/v1 +metadata: + name: foo +spec: + steps: + - name: step1 + image: scratch` +const emptyStr = "empty" + +func TestGetSelector(t *testing.T) { + resolver := Resolver{} + 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 TestValidate(t *testing.T) { + testCases := []struct { + name string + url string + expectedErr error + }{ + { + name: "valid/url", + url: "https://raw.githubusercontent.com/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 := Resolver{} + params := map[string]string{} + if tc.url != "nourl" { + params[httpv1.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 TestResolve(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: httpv1.UrlParam, + Value: *pipelinev1.NewStructuredValues(svr.URL), + }) + } + resolver := Resolver{} + 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 TestResolveNotEnabled(t *testing.T) { + var err error + resolver := Resolver{} + 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 TestResolverReconcileBasicAuth(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: resolution.CreateResolutionRequestStatusWithData([]byte(sampleTask)), + }, + { + name: "good/URL Resolution with custom basic auth, and custom secret key", + taskContent: sampleTask, + expectedStatus: resolution.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: resolution.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 := &Resolver{} + 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.RunResolverReconcileTest(ctx, t, d, resolver, request, expectedStatus, tt.expectedErr, func(resolver framework.Resolver, 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 TestGetName(t *testing.T) { + resolver := Resolver{} + 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)) + } +} + +func resolverDisabledContext() context.Context { + return frtestingV1.ContextWithHttpResolverDisabled(context.Background()) +} + +func toParams(m map[string]string) []pipelinev1.Param { + var params []pipelinev1.Param + + for k, v := range m { + params = append(params, pipelinev1.Param{ + Name: k, + Value: *pipelinev1.NewStructuredValues(v), + }) + } + + return params +} + +func contextWithConfig(timeout string) context.Context { + config := map[string]string{ + httpv1.TimeoutKey: timeout, + } + return frameworkV1.InjectResolverConfigToContext(context.Background(), config) +} + +func checkExpectedErr(t *testing.T, expectedErr, actualErr error) { + t.Helper() + if actualErr == nil { + t.Fatalf("expected err '%v' but didn't get one", expectedErr) + } + if d := cmp.Diff(expectedErr.Error(), actualErr.Error()); d != "" { + t.Fatalf("expected err '%v' but got '%v'", expectedErr, actualErr) + } +} + +func createRequest(params *params) *v1beta1.ResolutionRequest { + rr := &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: LabelValueHttpResolverType, + }, + }, + Spec: v1beta1.ResolutionRequestSpec{ + Params: []pipelinev1.Param{{ + Name: httpv1.UrlParam, + Value: *pipelinev1.NewStructuredValues(params.url), + }}, + }, + } + if params.authSecret != "" { + s := params.authSecret + if s == emptyStr { + s = "" + } + rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ + Name: httpv1.HttpBasicAuthSecret, + Value: *pipelinev1.NewStructuredValues(s), + }) + } + + if params.authUsername != "" { + s := params.authUsername + if s == emptyStr { + s = "" + } + rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ + Name: httpv1.HttpBasicAuthUsername, + Value: *pipelinev1.NewStructuredValues(s), + }) + } + + if params.authSecretKey != "" { + rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ + Name: httpv1.HttpBasicAuthSecretKey, + Value: *pipelinev1.NewStructuredValues(params.authSecretKey), + }) + } + + return rr +} diff --git a/pkg/remoteresolution/resolver/hub/resolver.go b/pkg/remoteresolution/resolver/hub/resolver.go new file mode 100644 index 00000000000..5842c27bae9 --- /dev/null +++ b/pkg/remoteresolution/resolver/hub/resolver.go @@ -0,0 +1,78 @@ +/* +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" + + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/framework" + "github.com/tektoncd/pipeline/pkg/resolution/common" + frameworkV1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + hubv1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/hub" +) + +const ( + // LabelValueHubResolverType is the value to use for the + // resolution.tekton.dev/type label on resource requests + LabelValueHubResolverType string = "hub" + + // ArtifactHubType is the value to use setting the type field to artifact + ArtifactHubType string = "artifact" + + // TektonHubType is the value to use setting the type field to tekton + TektonHubType string = "tekton" +) + +var _ framework.Resolver = &Resolver{} + +// Resolver implements a framework.Resolver that can fetch files from OCI bundles. +type Resolver 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 *Resolver) Initialize(context.Context) error { + return nil +} + +// GetName returns a string name to refer to this resolver by. +func (r *Resolver) GetName(context.Context) string { + return "Hub" +} + +// GetConfigName returns the name of the bundle resolver's configmap. +func (r *Resolver) GetConfigName(context.Context) string { + return "hubresolver-config" +} + +// 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: LabelValueHubResolverType, + } +} + +// Validate ensures parameters from a request are as expected. +func (r *Resolver) Validate(ctx context.Context, req *v1beta1.ResolutionRequestSpec) error { + return hubv1.ValidateParams(ctx, req.Params, r.TektonHubURL) +} + +// Resolve uses the given params to resolve the requested file or resource. +func (r *Resolver) Resolve(ctx context.Context, req *v1beta1.ResolutionRequestSpec) (frameworkV1.ResolvedResource, error) { + return hubv1.Resolve(ctx, req.Params, r.TektonHubURL, r.ArtifactHubURL) +} diff --git a/pkg/remoteresolution/resolver/hub/resolver_test.go b/pkg/remoteresolution/resolver/hub/resolver_test.go new file mode 100644 index 00000000000..86bc8ff6dca --- /dev/null +++ b/pkg/remoteresolution/resolver/hub/resolver_test.go @@ -0,0 +1,312 @@ +/* +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" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/go-cmp/cmp" + 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" + frameworkV1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" + hubv1 "github.com/tektoncd/pipeline/pkg/resolution/resolver/hub" + "github.com/tektoncd/pipeline/test/diff" +) + +func TestGetSelector(t *testing.T) { + resolver := Resolver{} + 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 TestValidate(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 := Resolver{} + params := map[string]string{ + hubv1.ParamKind: tc.kind, + hubv1.ParamName: tc.resourceName, + hubv1.ParamVersion: tc.version, + hubv1.ParamCatalog: tc.catalog, + hubv1.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 TestValidateMissing(t *testing.T) { + resolver := Resolver{} + + var err error + + paramsMissingName := map[string]string{ + hubv1.ParamKind: "foo", + hubv1.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{ + hubv1.ParamKind: "foo", + hubv1.ParamName: "bar", + } + req = v1beta1.ResolutionRequestSpec{ + Params: toParams(paramsMissingVersion), + } + err = resolver.Validate(contextWithConfig(), &req) + + if err == nil { + t.Fatalf("expected missing version err") + } +} + +func TestValidateConflictingKindName(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 := Resolver{} + params := map[string]string{ + hubv1.ParamKind: tc.kind, + hubv1.ParamName: tc.name, + hubv1.ParamVersion: tc.version, + hubv1.ParamCatalog: tc.catalog, + hubv1.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 TestResolve(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 := &Resolver{ + TektonHubURL: svr.URL, + ArtifactHubURL: svr.URL, + } + + params := map[string]string{ + hubv1.ParamKind: tc.kind, + hubv1.ParamName: tc.imageName, + hubv1.ParamVersion: tc.version, + hubv1.ParamCatalog: tc.catalog, + hubv1.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)) + } + } + }) + } +} + +func toParams(m map[string]string) []pipelinev1.Param { + var params []pipelinev1.Param + + for k, v := range m { + params = append(params, pipelinev1.Param{ + Name: k, + Value: *pipelinev1.NewStructuredValues(v), + }) + } + + return params +} + +func contextWithConfig() context.Context { + config := map[string]string{ + "default-tekton-hub-catalog": "Tekton", + "default-artifact-hub-task-catalog": "tekton-catalog-tasks", + "default-artifact-hub-pipeline-catalog": "tekton-catalog-pipelines", + "default-type": "artifact", + } + + return frameworkV1.InjectResolverConfigToContext(context.Background(), config) +} + +func checkExpectedErr(t *testing.T, expectedErr, actualErr error) { + t.Helper() + if actualErr == nil { + t.Fatalf("expected err '%v' but didn't get one", expectedErr) + } + if d := cmp.Diff(expectedErr.Error(), actualErr.Error()); d != "" { + t.Fatalf("expected err '%v' but got '%v'", expectedErr, actualErr) + } +} diff --git a/pkg/remoteresolution/resource/crd_resource.go b/pkg/remoteresolution/resource/crd_resource.go new file mode 100644 index 00000000000..7f86116be8c --- /dev/null +++ b/pkg/remoteresolution/resource/crd_resource.go @@ -0,0 +1,92 @@ +/* +Copyright 2022 The Tekton Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package resource + +import ( + "context" + "errors" + + 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" + resourcev1 "github.com/tektoncd/pipeline/pkg/resolution/resource" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/pkg/apis" +) + +// CRDRequester implements the Requester interface using +// ResolutionRequest CRDs. +type CRDRequester struct { + clientset rrclient.Interface + lister rrlisters.ResolutionRequestLister +} + +// NewCRDRequester 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 NewCRDRequester(clientset rrclient.Interface, lister rrlisters.ResolutionRequestLister) *CRDRequester { + return &CRDRequester{clientset, lister} +} + +var _ Requester = &CRDRequester{} + +// 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 *CRDRequester) Submit(ctx context.Context, resolver ResolverName, req Request) (ResolvedResource, error) { + rr, _ := r.lister.ResolutionRequests(req.ResolverPayload().Namespace).Get(req.ResolverPayload().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 resourcev1.CrdIntoResource(rr), nil + } + + message := rr.Status.GetCondition(apis.ConditionSucceeded).GetMessage() + err := resolutioncommon.NewError(resolutioncommon.ReasonResolutionFailed, errors.New(message)) + return nil, err +} + +func (r *CRDRequester) createResolutionRequest(ctx context.Context, resolver ResolverName, req Request) error { + var owner metav1.OwnerReference + if ownedReq, ok := req.(OwnedRequest); ok { + owner = ownedReq.OwnerRef() + } + rr := resourcev1.CreateResolutionRequest(ctx, resolver, req.ResolverPayload().Name, req.ResolverPayload().Namespace, req.ResolverPayload().ResolutionSpec.Params, owner) + _, err := r.clientset.ResolutionV1beta1().ResolutionRequests(rr.Namespace).Create(ctx, rr, metav1.CreateOptions{}) + return err +} diff --git a/pkg/remoteresolution/resource/crd_resource_test.go b/pkg/remoteresolution/resource/crd_resource_test.go new file mode 100644 index 00000000000..fb1c47f536a --- /dev/null +++ b/pkg/remoteresolution/resource/crd_resource_test.go @@ -0,0 +1,324 @@ +/* +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 ( + "context" + "encoding/base64" + "errors" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + ttesting "github.com/tektoncd/pipeline/pkg/reconciler/testing" + "github.com/tektoncd/pipeline/pkg/remoteresolution/resource" + resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + "github.com/tektoncd/pipeline/test" + "github.com/tektoncd/pipeline/test/diff" + resolution "github.com/tektoncd/pipeline/test/remoteresolution" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/pkg/logging" + _ "knative.dev/pkg/system/testing" // Setup system.Namespace() + "sigs.k8s.io/yaml" +) + +// getCRDRequester returns an instance of the CRDRequester that has been seeded with +// d, where d represents the state of the system (existing resources) needed for the test. +func getCRDRequester(t *testing.T, d test.Data) (test.Assets, func()) { + t.Helper() + return initializeCRDRequesterAssets(t, d) +} + +func initializeCRDRequesterAssets(t *testing.T, d test.Data) (test.Assets, func()) { + t.Helper() + ctx, _ := ttesting.SetupFakeContext(t) + ctx, cancel := context.WithCancel(ctx) + c, informers := test.SeedTestData(t, ctx, d) + + return test.Assets{ + Logger: logging.FromContext(ctx), + Clients: c, + Informers: informers, + Ctx: ctx, + }, cancel +} + +func TestCRDRequesterSubmit(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 := mustParseRawRequest(t, ` +resolverPayload: + name: git-ec247f5592afcaefa8485e34d2bd80c6 + namespace: namespace + resolutionSpec: + 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.RawRequest + 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") + crdRequester := resource.NewCRDRequester(clients.ResolutionRequests, testAssets.Informers.ResolutionRequest.Lister()) + requestWithOwner := &ownerRequest{ + Request: tc.inputRequest.Request(), + ownerRef: *ownerRef, + } + resolvedResource, err := crdRequester.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.ResolverPayload.Namespace).Get(ctx, tc.inputRequest.ResolverPayload.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 ownerRequest struct { + resource.Request + ownerRef metav1.OwnerReference +} + +func (r *ownerRequest) OwnerRef() metav1.OwnerReference { + return r.ownerRef +} + +func mustParseRawRequest(t *testing.T, yamlStr string) *resolution.RawRequest { + t.Helper() + output := &resolution.RawRequest{} + if err := yaml.Unmarshal([]byte(yamlStr), output); err != nil { + t.Errorf("parsing raw request %s: %v", yamlStr, err) + } + return output +} + +func mustParseOwnerReference(t *testing.T, yamlStr string) *metav1.OwnerReference { + t.Helper() + output := &metav1.OwnerReference{} + if err := yaml.Unmarshal([]byte(yamlStr), output); err != nil { + t.Errorf("parsing owner reference %s: %v", yamlStr, err) + } + return output +} + +func mustParseResolutionRequest(t *testing.T, yamlStr string) *v1beta1.ResolutionRequest { + t.Helper() + output := &v1beta1.ResolutionRequest{} + if err := yaml.Unmarshal([]byte(yamlStr), output); err != nil { + t.Errorf("parsing resolution request %s: %v", yamlStr, err) + } + return output +} + +func mustParseResolutionRequestStatus(t *testing.T, yamlStr string) *v1beta1.ResolutionRequestStatus { + t.Helper() + output := &v1beta1.ResolutionRequestStatus{} + if err := yaml.Unmarshal([]byte(yamlStr), output); err != nil { + t.Errorf("parsing resolution request status %s: %v", yamlStr, err) + } + return output +} + +func assertResolvedResourceEqual(t *testing.T, expected, actual resolutioncommon.ResolvedResource) { + t.Helper() + expectedBytes, err := expected.Data() + if err != nil { + t.Errorf("unexpected error getting expected resource data: %v", err) + } + actualBytes, err := actual.Data() + if err != nil { + t.Errorf("unexpected error getting acutal resource data: %v", err) + } + if d := cmp.Diff(expectedBytes, actualBytes); d != "" { + t.Errorf("expected resolved resource Data to match %s", diff.PrintWantGot(d)) + } + if d := cmp.Diff(expected.Annotations(), actual.Annotations()); d != "" { + t.Errorf("expected resolved resource Annotations to match %s", diff.PrintWantGot(d)) + } + if d := cmp.Diff(expected.RefSource(), actual.RefSource()); d != "" { + t.Errorf("expected resolved resource Source to match %s", diff.PrintWantGot(d)) + } +} diff --git a/pkg/remoteresolution/resource/request.go b/pkg/remoteresolution/resource/request.go new file mode 100644 index 00000000000..69f2c97ff34 --- /dev/null +++ b/pkg/remoteresolution/resource/request.go @@ -0,0 +1,83 @@ +/* +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" + + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" +) + +type BasicRequest struct { + resolverPayload ResolverPayload +} + +var _ Request = &BasicRequest{} + +// NewRequest returns an instance of a BasicRequestV2 with the given resolverPayload. +func NewRequest(resolverPayload ResolverPayload) Request { + return &BasicRequest{resolverPayload} +} + +var _ Request = &BasicRequest{} + +// Params are the map of parameters associated with this request +func (req *BasicRequest) ResolverPayload() ResolverPayload { + return req.resolverPayload +} + +// Requester is the interface implemented by a type that knows how to +// submit requests for remote resources. +type Requester 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 Request) (ResolvedResource, error) +} + +// 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 +// a request has particular properties, whether the request should be made +// to a specific namespace, and precisely which parameters should be included. +type Request interface { + ResolverPayload() ResolverPayload +} + +// ResolverPayload is the struct which holds the payload to create +// the Resolution Request CRD. +type ResolverPayload struct { + Name string + Namespace string + ResolutionSpec *v1beta1.ResolutionRequestSpec +} + +// ResolutionRequester is the interface implemented by a type that knows how to +// submit requests for remote resources. +type ResolutionRequester interface { + // SubmitResolutionRequest accepts the name of a resolver to submit a request to + // along with the request itself. + SubmitResolutionRequest(ctx context.Context, name ResolverName, req RequestRemoteResource) (ResolvedResource, error) +} + +// RequestRemoteResource 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 RequestRemoteResource interface { + ResolverPayload() ResolverPayload +} diff --git a/pkg/remoteresolution/resource/request_test.go b/pkg/remoteresolution/resource/request_test.go new file mode 100644 index 00000000000..1860d1aa87d --- /dev/null +++ b/pkg/remoteresolution/resource/request_test.go @@ -0,0 +1,73 @@ +/* +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/apis/resolution/v1beta1" + "github.com/tektoncd/pipeline/pkg/remoteresolution/resource" + "github.com/tektoncd/pipeline/test/diff" +) + +func TestNewRequest(t *testing.T) { + type args struct { + resolverPayload resource.ResolverPayload + } + type want = args + golden := args{ + resolverPayload: resource.ResolverPayload{ + Name: "test-name", + Namespace: "test-namespace", + ResolutionSpec: &v1beta1.ResolutionRequestSpec{ + 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.NewRequest(tt.args.resolverPayload) + if request == nil { + t.Errorf("NewRequest() return nil") + } + if d := cmp.Diff(tt.want.resolverPayload, request.ResolverPayload()); d != "" { + t.Errorf("expected params to match %s", diff.PrintWantGot(d)) + } + }) + } +} diff --git a/pkg/remoteresolution/resource/resource.go b/pkg/remoteresolution/resource/resource.go new file mode 100644 index 00000000000..dd19a9f86b8 --- /dev/null +++ b/pkg/remoteresolution/resource/resource.go @@ -0,0 +1,37 @@ +/* +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 ( + "github.com/tektoncd/pipeline/pkg/resolution/common" +) + +// This is an alias for avoiding cycle import + +// ResolverName is the type used for a resolver's name and is mostly +// used to ensure the function signatures that accept it are clear on the +// purpose for the given string. +type ResolverName = common.ResolverName + +// OwnedRequest is implemented by any type implementing Request that also needs +// to express a Kubernetes OwnerRef relationship as part of the request being +// made. +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 diff --git a/pkg/resolution/resolver/bundle/resolver.go b/pkg/resolution/resolver/bundle/resolver.go index a5cd07ac5f3..b23b2f7a959 100644 --- a/pkg/resolution/resolver/bundle/resolver.go +++ b/pkg/resolution/resolver/bundle/resolver.go @@ -25,7 +25,9 @@ import ( 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/resolution/common" + v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + common "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" @@ -34,14 +36,14 @@ import ( const ( disabledError = "cannot handle resolution request, enable-bundles-resolver feature flag not true" - // LabelValueBundleResolverType is the value to use for the - // resolution.tekton.dev/type label on resource requests - LabelValueBundleResolverType string = "bundles" - // TODO(sbwsg): This should be exposed as a configurable option for // admins (e.g. via ConfigMap) timeoutDuration = time.Minute + // LabelValueBundleResolverType is the value to use for the + // resolution.tekton.dev/type label on resource requests + LabelValueBundleResolverType string = "bundles" + // BundleResolverName is the name that the bundle resolver should be associated with. BundleResolverName = "bundleresolver" ) @@ -76,21 +78,20 @@ 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 []v1.Param) (framework.ResolvedResource, error) { + return ResolveRequest(ctx, r.kubeClientSet, &v1beta1.ResolutionRequestSpec{Params: 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 ResolveRequest(ctx context.Context, kubeClientSet kubernetes.Interface, req *v1beta1.ResolutionRequestSpec) (framework.ResolvedResource, error) { + if isDisabled(ctx) { return nil, errors.New(disabledError) } - opts, err := OptionsFromParams(ctx, params) + opts, err := OptionsFromParams(ctx, req.Params) if err != nil { return nil, err } @@ -99,7 +100,7 @@ func (r *Resolver) Resolve(ctx context.Context, params []pipelinev1.Param) (fram imagePullSecrets = append(imagePullSecrets, opts.ImagePullSecret) } namespace := common.RequestNamespace(ctx) - kc, err := k8schain.New(ctx, r.kubeClientSet, k8schain.Options{ + kc, err := k8schain.New(ctx, kubeClientSet, k8schain.Options{ Namespace: namespace, ImagePullSecrets: imagePullSecrets, ServiceAccountName: kauth.NoServiceAccount, @@ -112,7 +113,17 @@ func (r *Resolver) Resolve(ctx context.Context, params []pipelinev1.Param) (fram return GetEntry(ctx, kc, opts) } -func (r *Resolver) isDisabled(ctx context.Context) bool { +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_test.go b/pkg/resolution/resolver/bundle/resolver_test.go index 4f575d00b8f..2cdc8571631 100644 --- a/pkg/resolution/resolver/bundle/resolver_test.go +++ b/pkg/resolution/resolver/bundle/resolver_test.go @@ -32,11 +32,11 @@ import ( 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" + "github.com/tektoncd/pipeline/pkg/internal/resolution" ttesting "github.com/tektoncd/pipeline/pkg/reconciler/testing" - resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + common "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" @@ -56,7 +56,7 @@ const ( func TestGetSelector(t *testing.T) { resolver := bundle.Resolver{} sel := resolver.GetSelector(context.Background()) - if typ, has := sel[resolutioncommon.LabelKeyResolverType]; !has { + if typ, has := sel[common.LabelKeyResolverType]; !has { t.Fatalf("unexpected selector: %v", sel) } else if typ != bundle.LabelValueBundleResolverType { t.Fatalf("unexpected type: %q", typ) @@ -347,7 +347,7 @@ func TestResolve(t *testing.T) { kind: "task", }, imageName: "single-task", - expectedStatus: internal.CreateResolutionRequestStatusWithData(taskAsYAML), + expectedStatus: resolution.CreateResolutionRequestStatusWithData(taskAsYAML), }, { name: "single task: param kind is capitalized, but kind in bundle is not", args: ¶ms{ @@ -357,7 +357,7 @@ func TestResolve(t *testing.T) { }, kindInBundle: "task", imageName: "single-task", - expectedStatus: internal.CreateResolutionRequestStatusWithData(taskAsYAML), + expectedStatus: resolution.CreateResolutionRequestStatusWithData(taskAsYAML), }, { name: "single task: tag is included in the bundle parameter", args: ¶ms{ @@ -366,7 +366,7 @@ func TestResolve(t *testing.T) { kind: "task", }, imageName: "single-task", - expectedStatus: internal.CreateResolutionRequestStatusWithData(taskAsYAML), + expectedStatus: resolution.CreateResolutionRequestStatusWithData(taskAsYAML), }, { name: "single task: using default kind value from configmap", args: ¶ms{ @@ -374,7 +374,7 @@ func TestResolve(t *testing.T) { name: "example-task", }, imageName: "single-task", - expectedStatus: internal.CreateResolutionRequestStatusWithData(taskAsYAML), + expectedStatus: resolution.CreateResolutionRequestStatusWithData(taskAsYAML), }, { name: "single pipeline", args: ¶ms{ @@ -383,7 +383,7 @@ func TestResolve(t *testing.T) { kind: "pipeline", }, imageName: "single-pipeline", - expectedStatus: internal.CreateResolutionRequestStatusWithData(pipelineAsYAML), + expectedStatus: resolution.CreateResolutionRequestStatusWithData(pipelineAsYAML), }, { name: "multiple resources: an image has both task and pipeline resource", args: ¶ms{ @@ -392,7 +392,7 @@ func TestResolve(t *testing.T) { kind: "pipeline", }, imageName: "multiple-resources", - expectedStatus: internal.CreateResolutionRequestStatusWithData(pipelineAsYAML), + expectedStatus: resolution.CreateResolutionRequestStatusWithData(pipelineAsYAML), }, { name: "too many objects in an image", args: ¶ms{ @@ -400,7 +400,7 @@ func TestResolve(t *testing.T) { name: "2-task", kind: "task", }, - expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedStatus: resolution.CreateResolutionRequestFailureStatus(), expectedErrMessage: fmt.Sprintf("contained more than the maximum %d allow objects", bundle.MaximumBundleObjects), }, { name: "single task no version", @@ -409,7 +409,7 @@ func TestResolve(t *testing.T) { name: "foo", kind: "task", }, - expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedStatus: resolution.CreateResolutionRequestFailureStatus(), expectedErrMessage: fmt.Sprintf("the layer 0 does not contain a %s annotation", bundle.BundleAnnotationAPIVersion), }, { name: "single task no kind", @@ -418,7 +418,7 @@ func TestResolve(t *testing.T) { name: "foo", kind: "task", }, - expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedStatus: resolution.CreateResolutionRequestFailureStatus(), expectedErrMessage: fmt.Sprintf("the layer 0 does not contain a %s annotation", bundle.BundleAnnotationKind), }, { name: "single task no name", @@ -427,7 +427,7 @@ func TestResolve(t *testing.T) { name: "foo", kind: "task", }, - expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedStatus: resolution.CreateResolutionRequestFailureStatus(), expectedErrMessage: fmt.Sprintf("the layer 0 does not contain a %s annotation", bundle.BundleAnnotationName), }, { name: "single task kind incorrect form", @@ -436,7 +436,7 @@ func TestResolve(t *testing.T) { name: "foo", kind: "task", }, - expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedStatus: resolution.CreateResolutionRequestFailureStatus(), expectedErrMessage: fmt.Sprintf("the layer 0 the annotation %s must be lowercased and singular, found %s", bundle.BundleAnnotationKind, "Task"), }, } @@ -521,7 +521,7 @@ func createRequest(p *params) *v1beta1.ResolutionRequest { Namespace: "foo", CreationTimestamp: metav1.Time{Time: time.Now()}, Labels: map[string]string{ - resolutioncommon.LabelKeyResolverType: bundle.LabelValueBundleResolverType, + common.LabelKeyResolverType: bundle.LabelValueBundleResolverType, }, }, Spec: v1beta1.ResolutionRequestSpec{ @@ -544,7 +544,7 @@ func createRequest(p *params) *v1beta1.ResolutionRequest { } func createError(image, msg string) error { - return &resolutioncommon.GetResourceError{ + return &common.GetResourceError{ ResolverName: bundle.BundleResolverName, Key: "foo/rr", Original: fmt.Errorf("invalid tekton bundle %s, error: %s", image, msg), diff --git a/pkg/resolution/resolver/cluster/resolver.go b/pkg/resolution/resolver/cluster/resolver.go index 6483016b93b..f3781cbef33 100644 --- a/pkg/resolution/resolver/cluster/resolver.go +++ b/pkg/resolution/resolver/cluster/resolver.go @@ -27,7 +27,7 @@ import ( 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" + common "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" @@ -71,25 +71,24 @@ func (r *Resolver) GetName(_ context.Context) string { // the cluster resolver to process them. func (r *Resolver) GetSelector(_ context.Context) map[string]string { return map[string]string{ - resolutioncommon.LabelKeyResolverType: LabelValueClusterResolverType, + common.LabelKeyResolverType: LabelValueClusterResolverType, } } // 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 ResolveFromParams(ctx, origParams, r.pipelineClientSet) +} + +func ResolveFromParams(ctx context.Context, origParams []pipelinev1.Param, pipelineClientSet clientset.Interface) (framework.ResolvedResource, error) { + if isDisabled(ctx) { return nil, errors.New(disabledError) } @@ -109,52 +108,23 @@ func (r *Resolver) Resolve(ctx context.Context, origParams []pipelinev1.Param) ( switch params[KindParam] { case "task": - task, err := r.pipelineClientSet.TektonV1().Tasks(params[NamespaceParam]).Get(ctx, params[NameParam], metav1.GetOptions{}) + 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 = 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) + uid, data, sha256Checksum, spec, err = fetchTask(ctx, groupVersion, task, params) 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{}) + 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 = string(pipeline.UID) - pipeline.Kind = "Pipeline" - pipeline.APIVersion = groupVersion - data, err = yaml.Marshal(pipeline) + uid, data, sha256Checksum, spec, err = fetchPipeline(ctx, groupVersion, pipeline, params) 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: @@ -179,11 +149,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 { @@ -302,3 +267,62 @@ func isInCommaSeparatedList(checkVal string, commaList string) bool { } return false } +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_test.go b/pkg/resolution/resolver/cluster/resolver_test.go index e1051a7089d..6cfdf36ac70 100644 --- a/pkg/resolution/resolver/cluster/resolver_test.go +++ b/pkg/resolution/resolver/cluster/resolver_test.go @@ -30,12 +30,12 @@ import ( 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" + "github.com/tektoncd/pipeline/pkg/internal/resolution" ttesting "github.com/tektoncd/pipeline/pkg/reconciler/testing" - resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + common "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" @@ -53,7 +53,7 @@ const ( func TestGetSelector(t *testing.T) { resolver := cluster.Resolver{} sel := resolver.GetSelector(context.Background()) - if typ, has := sel[resolutioncommon.LabelKeyResolverType]; !has { + if typ, has := sel[common.LabelKeyResolverType]; !has { t.Fatalf("unexpected selector: %v", sel) } else if typ != cluster.LabelValueClusterResolverType { t.Fatalf("unexpected type: %q", typ) @@ -360,8 +360,8 @@ func TestResolve(t *testing.T) { kind: "task", resourceName: exampleTask.Name, namespace: "other-ns", - expectedStatus: internal.CreateResolutionRequestFailureStatus(), - expectedErr: &resolutioncommon.GetResourceError{ + expectedStatus: resolution.CreateResolutionRequestFailureStatus(), + expectedErr: &common.GetResourceError{ ResolverName: cluster.ClusterResolverName, Key: "foo/rr", Original: errors.New(`tasks.tekton.dev "example-task" not found`), @@ -372,8 +372,8 @@ func TestResolve(t *testing.T) { resourceName: exampleTask.Name, namespace: "other-ns", allowedNamespaces: "foo,bar", - expectedStatus: internal.CreateResolutionRequestFailureStatus(), - expectedErr: &resolutioncommon.InvalidRequestError{ + expectedStatus: resolution.CreateResolutionRequestFailureStatus(), + expectedErr: &common.InvalidRequestError{ ResolutionRequestKey: "foo/rr", Message: "access to specified namespace other-ns is not allowed", }, @@ -383,8 +383,8 @@ func TestResolve(t *testing.T) { resourceName: exampleTask.Name, namespace: "other-ns", blockedNamespaces: "foo,other-ns,bar", - expectedStatus: internal.CreateResolutionRequestFailureStatus(), - expectedErr: &resolutioncommon.InvalidRequestError{ + expectedStatus: resolution.CreateResolutionRequestFailureStatus(), + expectedErr: &common.InvalidRequestError{ ResolutionRequestKey: "foo/rr", Message: "access to specified namespace other-ns is blocked", }, @@ -471,7 +471,7 @@ func createRequest(kind, name, namespace string) *v1beta1.ResolutionRequest { Namespace: "foo", CreationTimestamp: metav1.Time{Time: time.Now()}, Labels: map[string]string{ - resolutioncommon.LabelKeyResolverType: cluster.LabelValueClusterResolverType, + common.LabelKeyResolverType: cluster.LabelValueClusterResolverType, }, }, Spec: v1beta1.ResolutionRequestSpec{ diff --git a/pkg/resolution/resolver/framework/controller.go b/pkg/resolution/resolver/framework/controller.go index f1d270a398a..61bc3c04626 100644 --- a/pkg/resolution/resolver/framework/controller.go +++ b/pkg/resolution/resolver/framework/controller.go @@ -46,7 +46,7 @@ type ReconcilerModifier = func(reconciler *Reconciler) // This sets up a lot of the boilerplate that individual resolvers // shouldn't need to be concerned with since it's common to all of them. func NewController(ctx context.Context, resolver Resolver, modifiers ...ReconcilerModifier) func(context.Context, configmap.Watcher) *controller.Impl { - if err := validateResolver(ctx, resolver); err != nil { + if err := ValidateResolver(ctx, resolver.GetSelector(ctx)); err != nil { panic(err.Error()) } return func(ctx context.Context, cmw configmap.Watcher) *controller.Impl { @@ -60,7 +60,7 @@ func NewController(ctx context.Context, resolver Resolver, modifiers ...Reconcil } r := &Reconciler{ - LeaderAwareFuncs: leaderAwareFuncs(rrInformer.Lister()), + LeaderAwareFuncs: LeaderAwareFuncs(rrInformer.Lister()), kubeClientSet: kubeclientset, resolutionRequestLister: rrInformer.Lister(), resolutionRequestClientSet: rrclientset, @@ -82,7 +82,7 @@ func NewController(ctx context.Context, resolver Resolver, modifiers ...Reconcil }) _, err := rrInformer.Informer().AddEventHandler(cache.FilteringResourceEventHandler{ - FilterFunc: filterResolutionRequestsBySelector(resolver.GetSelector(ctx)), + FilterFunc: FilterResolutionRequestsBySelector(resolver.GetSelector(ctx)), Handler: cache.ResourceEventHandlerFuncs{ AddFunc: impl.Enqueue, UpdateFunc: func(oldObj, newObj interface{}) { @@ -101,7 +101,35 @@ func NewController(ctx context.Context, resolver Resolver, modifiers ...Reconcil } } -func filterResolutionRequestsBySelector(selector map[string]string) func(obj interface{}) bool { +// watchConfigChanges binds a framework.Resolver to updates on its +// configmap, using knative's configmap helpers. This is only done if +// the resolver implements the framework.ConfigWatcher interface. +func watchConfigChanges(ctx context.Context, reconciler *Reconciler, cmw configmap.Watcher) { + if configWatcher, ok := reconciler.resolver.(ConfigWatcher); ok { + logger := logging.FromContext(ctx) + resolverConfigName := configWatcher.GetConfigName(ctx) + if resolverConfigName == "" { + panic("resolver returned empty config name") + } + reconciler.configStore = NewConfigStore(resolverConfigName, logger) + reconciler.configStore.WatchConfigs(cmw) + } +} + +// applyModifiersAndDefaults applies the given modifiers to +// a reconciler and, after doing so, sets any default values for things +// that weren't set by a modifier. +func applyModifiersAndDefaults(ctx context.Context, r *Reconciler, modifiers []ReconcilerModifier) { + for _, mod := range modifiers { + mod(r) + } + + if r.Clock == nil { + r.Clock = clock.RealClock{} + } +} + +func FilterResolutionRequestsBySelector(selector map[string]string) func(obj interface{}) bool { return func(obj interface{}) bool { rr, ok := obj.(*v1beta1.ResolutionRequest) if !ok { @@ -127,7 +155,7 @@ func filterResolutionRequestsBySelector(selector map[string]string) func(obj int // fact that the controller crashes if they're missing. It looks // like this is bucketing based on labels. Should we use the filter // selector from above in the call to lister.List here? -func leaderAwareFuncs(lister rrlister.ResolutionRequestLister) reconciler.LeaderAwareFuncs { +func LeaderAwareFuncs(lister rrlister.ResolutionRequestLister) reconciler.LeaderAwareFuncs { return reconciler.LeaderAwareFuncs{ PromoteFunc: func(bkt reconciler.Bucket, enq func(reconciler.Bucket, types.NamespacedName)) error { all, err := lister.List(labels.Everything()) @@ -156,8 +184,7 @@ var ( ErrorMissingTypeSelector = ErrMissingTypeSelector ) -func validateResolver(ctx context.Context, r Resolver) error { - sel := r.GetSelector(ctx) +func ValidateResolver(ctx context.Context, sel map[string]string) error { if sel == nil { return ErrMissingTypeSelector } @@ -166,31 +193,3 @@ func validateResolver(ctx context.Context, r Resolver) error { } return nil } - -// watchConfigChanges binds a framework.Resolver to updates on its -// configmap, using knative's configmap helpers. This is only done if -// the resolver implements the framework.ConfigWatcher interface. -func watchConfigChanges(ctx context.Context, reconciler *Reconciler, cmw configmap.Watcher) { - if configWatcher, ok := reconciler.resolver.(ConfigWatcher); ok { - logger := logging.FromContext(ctx) - resolverConfigName := configWatcher.GetConfigName(ctx) - if resolverConfigName == "" { - panic("resolver returned empty config name") - } - reconciler.configStore = NewConfigStore(resolverConfigName, logger) - reconciler.configStore.WatchConfigs(cmw) - } -} - -// applyModifiersAndDefaults applies the given modifiers to -// a reconciler and, after doing so, sets any default values for things -// that weren't set by a modifier. -func applyModifiersAndDefaults(ctx context.Context, r *Reconciler, modifiers []ReconcilerModifier) { - for _, mod := range modifiers { - mod(r) - } - - if r.Clock == nil { - r.Clock = clock.RealClock{} - } -} diff --git a/pkg/resolution/resolver/framework/fakeresolver.go b/pkg/resolution/resolver/framework/fakeresolver.go index 0943199601b..82f833a85c8 100644 --- a/pkg/resolution/resolver/framework/fakeresolver.go +++ b/pkg/resolution/resolver/framework/fakeresolver.go @@ -80,8 +80,12 @@ type FakeResolver struct { // Initialize performs any setup required by the fake resolver. func (r *FakeResolver) Initialize(ctx context.Context) error { - if r.ForParam == nil { - r.ForParam = make(map[string]*FakeResolvedResource) + return Initialize(r.ForParam) +} + +func Initialize(forParam map[string]*FakeResolvedResource) error { + if forParam == nil { + forParam = make(map[string]*FakeResolvedResource) } return nil } @@ -103,6 +107,10 @@ func (r *FakeResolver) GetSelector(_ context.Context) map[string]string { // ValidateParams returns an error if the given parameter map is not // valid for a resource request targeting the fake resolver. func (r *FakeResolver) ValidateParams(_ context.Context, params []pipelinev1.Param) error { + return ValidateParams(params) +} + +func ValidateParams(params []pipelinev1.Param) error { paramsMap := make(map[string]pipelinev1.ParamValue) for _, p := range params { paramsMap[p.Name] = p.Value @@ -132,6 +140,10 @@ func (r *FakeResolver) ValidateParams(_ context.Context, params []pipelinev1.Par // Resolve performs the work of fetching a file from the fake resolver given a map of // parameters. func (r *FakeResolver) Resolve(_ context.Context, params []pipelinev1.Param) (ResolvedResource, error) { + return Resolve(params, r.ForParam) +} + +func Resolve(params []pipelinev1.Param, forParam map[string]*FakeResolvedResource) (ResolvedResource, error) { paramsMap := make(map[string]pipelinev1.ParamValue) for _, p := range params { paramsMap[p.Name] = p.Value @@ -139,7 +151,7 @@ func (r *FakeResolver) Resolve(_ context.Context, params []pipelinev1.Param) (Re paramValue := paramsMap[FakeParamName].StringVal - frr, ok := r.ForParam[paramValue] + frr, ok := forParam[paramValue] if !ok { return nil, fmt.Errorf("couldn't find resource for param value %s", paramValue) } @@ -159,8 +171,12 @@ var _ TimedResolution = &FakeResolver{} // GetResolutionTimeout returns the configured timeout for the reconciler, or the default time.Duration if not configured. func (r *FakeResolver) GetResolutionTimeout(ctx context.Context, defaultTimeout time.Duration) time.Duration { - if r.Timeout > 0 { - return r.Timeout + return GetResolutionTimeout(r.Timeout, defaultTimeout) +} + +func GetResolutionTimeout(timeout, defaultTimeout time.Duration) time.Duration { + if timeout > 0 { + return timeout } return defaultTimeout } diff --git a/pkg/resolution/resolver/git/config.go b/pkg/resolution/resolver/git/config.go index a085bdfac39..44645b1fae6 100644 --- a/pkg/resolution/resolver/git/config.go +++ b/pkg/resolution/resolver/git/config.go @@ -17,20 +17,20 @@ limitations under the License. package git const ( - // defaultTimeoutKey is the configuration field name for controlling + // DefaultTimeoutKey is the configuration field name for controlling // the maximum duration of a resolution request for a file from git. - defaultTimeoutKey = "fetch-timeout" + DefaultTimeoutKey = "fetch-timeout" - // defaultURLKey is the configuration field name for controlling + // DefaultURLKey is the configuration field name for controlling // the git url to fetch the remote resource from. - defaultURLKey = "default-url" + DefaultURLKey = "default-url" - // defaultRevisionKey is the configuration field name for controlling + // DefaultRevisionKey is the configuration field name for controlling // the revision to fetch the remote resource from. - defaultRevisionKey = "default-revision" + DefaultRevisionKey = "default-revision" - // defaultOrgKey is the configuration field name for setting a default organization when using the SCM API. - defaultOrgKey = "default-org" + // DefaultOrgKey is the configuration field name for setting a default organization when using the SCM API. + DefaultOrgKey = "default-org" // ServerURLKey is the config map key for the SCM provider URL ServerURLKey = "server-url" diff --git a/pkg/resolution/resolver/git/params.go b/pkg/resolution/resolver/git/params.go index 679d0b0e9f5..46921fd1494 100644 --- a/pkg/resolution/resolver/git/params.go +++ b/pkg/resolution/resolver/git/params.go @@ -17,24 +17,24 @@ limitations under the License. package git const ( - // urlParam is the git repo url when using the anonymous/full clone approach - urlParam string = "url" - // orgParam is the organization to find the repository in when using the SCM API approach - orgParam = "org" - // repoParam is the repository to use when using the SCM API approach - repoParam = "repo" - // pathParam is the pathInRepo into the git repo where a file is located. This is used with both approaches. - pathParam string = "pathInRepo" - // revisionParam is the git revision that a file should be fetched from. This is used with both approaches. - revisionParam string = "revision" - // tokenParam is an optional reference to a secret name for SCM API authentication - tokenParam string = "token" - // tokenKeyParam is an optional reference to a key in the tokenParam secret for SCM API authentication - tokenKeyParam string = "tokenKey" - // defaultTokenKeyParam is the default key in the tokenParam secret for SCM API authentication - defaultTokenKeyParam string = "token" - // scmTypeParams is an optional string overriding the scm-type configuration (ie: github, gitea, gitlab etc..) - scmTypeParam string = "scmType" - // serverURLParams is an optional string to the server URL for the SCM API to connect to - serverURLParam string = "serverURL" + // UrlParam is the git repo Url when using the anonymous/full clone approach + UrlParam string = "Url" + // OrgParam is the organization to find the repository in when using the SCM API approach + OrgParam = "org" + // RepoParam is the repository to use when using the SCM API approach + RepoParam = "repo" + // PathParam is the pathInRepo into the git repo where a file is located. This is used with both approaches. + PathParam string = "pathInRepo" + // RevisionParam is the git revision that a file should be fetched from. This is used with both approaches. + RevisionParam string = "revision" + // TokenParam is an optional reference to a secret name for SCM API authentication + TokenParam string = "token" + // TokenKeyParam is an optional reference to a key in the TokenParam secret for SCM API authentication + TokenKeyParam string = "tokenKey" + // DefaultTokenKeyParam is the default key in the TokenParam secret for SCM API authentication + DefaultTokenKeyParam string = "token" + // scmTypeParam is an optional string overriding the scm-type configuration (ie: github, gitea, gitlab etc..) + ScmTypeParam string = "scmType" + // serverURLParam is an optional string to the server URL for the SCM API to connect to + ServerURLParam string = "serverURL" ) diff --git a/pkg/resolution/resolver/git/resolver.go b/pkg/resolution/resolver/git/resolver.go index 34fcd6ad18f..c8294de862f 100644 --- a/pkg/resolution/resolver/git/resolver.go +++ b/pkg/resolution/resolver/git/resolver.go @@ -36,8 +36,7 @@ import ( "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/resolution/common" - resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + common "github.com/tektoncd/pipeline/pkg/resolution/common" "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" "go.uber.org/zap" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -106,41 +105,45 @@ func (r *Resolver) GetName(_ context.Context) string { // the gitresolver to process them. func (r *Resolver) GetSelector(_ context.Context) map[string]string { return map[string]string{ - resolutioncommon.LabelKeyResolverType: labelValueGitResolverType, + common.LabelKeyResolverType: labelValueGitResolverType, } } // 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) } - params, err := populateDefaultParams(ctx, origParams) + params, err := PopulateDefaultParams(ctx, origParams) if err != nil { return nil, err } - if params[urlParam] != "" { - return r.resolveAnonymousGit(ctx, params) + if params[UrlParam] != "" { + 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,81 +155,19 @@ 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] + repo := params[UrlParam] if repo == "" { - if urlString, ok := conf[defaultURLKey]; ok { + if urlString, ok := conf[DefaultURLKey]; ok { repo = urlString } else { return nil, errors.New("default Git Repo Url was not set during installation of the git resolver") } } - revision := params[revisionParam] + revision := params[RevisionParam] if revision == "" { - if revisionString, ok := conf[defaultRevisionKey]; ok { + if revisionString, ok := conf[DefaultRevisionKey]; ok { revision = revisionString } else { return nil, errors.New("default Git Revision was not set during installation of the git resolver") @@ -271,7 +212,7 @@ func (r *Resolver) resolveAnonymousGit(ctx context.Context, params map[string]st return nil, fmt.Errorf("checkout error: %w", err) } - path := params[pathParam] + path := params[PathParam] f, err := filesystem.Open(path) if err != nil { @@ -287,8 +228,8 @@ func (r *Resolver) resolveAnonymousGit(ctx context.Context, params map[string]st return &resolvedGitResource{ Revision: h.String(), Content: buf.Bytes(), - URL: params[urlParam], - Path: params[pathParam], + URL: params[UrlParam], + Path: params[PathParam], }, nil } @@ -306,7 +247,7 @@ var _ framework.TimedResolution = &Resolver{} // fetch-timeout field in the git-resolver-config configmap. func (r *Resolver) GetResolutionTimeout(ctx context.Context, defaultTimeout time.Duration) time.Duration { conf := framework.GetResolverConfigFromContext(ctx) - if timeoutString, ok := conf[defaultTimeoutKey]; ok { + if timeoutString, ok := conf[DefaultTimeoutKey]; ok { timeout, err := time.ParseDuration(timeoutString) if err == nil { return timeout @@ -315,9 +256,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 @@ -342,10 +340,10 @@ func (r *resolvedGitResource) Data() []byte { // from git. func (r *resolvedGitResource) Annotations() map[string]string { m := map[string]string{ - AnnotationKeyRevision: r.Revision, - AnnotationKeyPath: r.Path, - AnnotationKeyURL: r.URL, - resolutioncommon.AnnotationKeyContentType: yamlContentType, + AnnotationKeyRevision: r.Revision, + AnnotationKeyPath: r.Path, + AnnotationKeyURL: r.URL, + common.AnnotationKeyContentType: yamlContentType, } if r.Org != "" { @@ -376,34 +374,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 @@ -417,15 +450,15 @@ 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) + err := fmt.Errorf("cannot get API token, required when specifying '%s' param, '%s' not specified in config", RepoParam, APISecretNameKey) + 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) + err := fmt.Errorf("cannot get API token, required when specifying '%s' param, '%s' not specified in config", RepoParam, APISecretKeyKey) + logger.Info(err) return nil, err } } @@ -436,94 +469,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_test.go b/pkg/resolution/resolver/git/resolver_test.go index a9f4c0490d9..86539145f00 100644 --- a/pkg/resolution/resolver/git/resolver_test.go +++ b/pkg/resolution/resolver/git/resolver_test.go @@ -36,11 +36,11 @@ import ( 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/internal/resolution" ttesting "github.com/tektoncd/pipeline/pkg/reconciler/testing" - resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + common "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" @@ -52,7 +52,7 @@ import ( func TestGetSelector(t *testing.T) { resolver := Resolver{} sel := resolver.GetSelector(context.Background()) - if typ, has := sel[resolutioncommon.LabelKeyResolverType]; !has { + if typ, has := sel[common.LabelKeyResolverType]; !has { t.Fatalf("unexpected selector: %v", sel) } else if typ != labelValueGitResolverType { t.Fatalf("unexpected type: %q", typ) @@ -68,57 +68,57 @@ func TestValidateParams(t *testing.T) { { name: "params with revision", params: map[string]string{ - urlParam: "http://foo/bar/hello/moto", - pathParam: "bar", - revisionParam: "baz", + 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", + 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", + 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", + 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", + 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", + UrlParam: "git@host.com:foo/bar", + PathParam: "bar", + RevisionParam: "baz", }, }, { name: "bad url", params: map[string]string{ - urlParam: "foo://bar", - pathParam: "path", - revisionParam: "revision", + UrlParam: "foo://bar", + PathParam: "path", + RevisionParam: "revision", }, wantErr: "invalid git repository url: foo://bar", }, @@ -147,8 +147,8 @@ func TestValidateParamsNotEnabled(t *testing.T) { var err error someParams := map[string]string{ - pathParam: "bar", - revisionParam: "baz", + PathParam: "bar", + RevisionParam: "baz", } err = resolver.ValidateParams(resolverDisabledContext(), toParams(someParams)) if err == nil { @@ -168,32 +168,32 @@ func TestValidateParams_Failure(t *testing.T) { { name: "missing multiple", params: map[string]string{ - orgParam: "abcd1234", - repoParam: "foo", + OrgParam: "abcd1234", + RepoParam: "foo", }, - expectedErr: fmt.Sprintf("missing required git resolver params: %s, %s", revisionParam, pathParam), + 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", + RevisionParam: "abcd1234", + PathParam: "/foo/bar", }, - expectedErr: "must specify one of 'url' or 'repo'", + 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", + RevisionParam: "abcd1234", + PathParam: "/foo/bar", + UrlParam: "http://foo", + RepoParam: "foo", }, - expectedErr: "cannot specify both 'url' and 'repo'", + expectedErr: "cannot specify both 'Url' and 'repo'", }, { name: "no org with repo", params: map[string]string{ - revisionParam: "abcd1234", - pathParam: "/foo/bar", - repoParam: "foo", + RevisionParam: "abcd1234", + PathParam: "/foo/bar", + RepoParam: "foo", }, expectedErr: "'org' is required when 'repo' is specified", }, @@ -227,7 +227,7 @@ func TestGetResolutionTimeoutCustom(t *testing.T) { defaultTimeout := 30 * time.Minute configTimeout := 5 * time.Second config := map[string]string{ - defaultTimeoutKey: configTimeout.String(), + DefaultTimeoutKey: configTimeout.String(), } ctx := framework.InjectResolverConfigToContext(context.Background(), config) timeout := resolver.GetResolutionTimeout(ctx, defaultTimeout) @@ -242,8 +242,8 @@ func TestResolveNotEnabled(t *testing.T) { var err error someParams := map[string]string{ - pathParam: "bar", - revisionParam: "baz", + PathParam: "bar", + RevisionParam: "baz", } _, err = resolver.Resolve(resolverDisabledContext(), toParams(someParams)) if err == nil { @@ -348,7 +348,7 @@ func TestResolve(t *testing.T) { url: anonFakeRepoURL, }, expectedCommitSHA: commitSHAsInAnonRepo[2], - expectedStatus: internal.CreateResolutionRequestStatusWithData([]byte("released content in main branch and in tag v1")), + expectedStatus: resolution.CreateResolutionRequestStatusWithData([]byte("released content in main branch and in tag v1")), }, { name: "clone: revision is tag name", args: ¶ms{ @@ -357,7 +357,7 @@ func TestResolve(t *testing.T) { url: anonFakeRepoURL, }, expectedCommitSHA: commitSHAsInAnonRepo[2], - expectedStatus: internal.CreateResolutionRequestStatusWithData([]byte("released content in main branch and in tag v1")), + expectedStatus: resolution.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{ @@ -366,7 +366,7 @@ func TestResolve(t *testing.T) { url: anonFakeRepoURL, }, expectedCommitSHA: commitSHAsInAnonRepo[2], - expectedStatus: internal.CreateResolutionRequestStatusWithData([]byte("released content in main branch and in tag v1")), + expectedStatus: resolution.CreateResolutionRequestStatusWithData([]byte("released content in main branch and in tag v1")), }, { name: "clone: revision is a branch name", args: ¶ms{ @@ -375,7 +375,7 @@ func TestResolve(t *testing.T) { url: anonFakeRepoURL, }, expectedCommitSHA: commitSHAsInAnonRepo[1], - expectedStatus: internal.CreateResolutionRequestStatusWithData([]byte("new content in test branch")), + expectedStatus: resolution.CreateResolutionRequestStatusWithData([]byte("new content in test branch")), }, { name: "clone: revision is a specific commit sha", args: ¶ms{ @@ -384,7 +384,7 @@ func TestResolve(t *testing.T) { url: anonFakeRepoURL, }, expectedCommitSHA: commitSHAsInAnonRepo[0], - expectedStatus: internal.CreateResolutionRequestStatusWithData([]byte("old content in test branch")), + expectedStatus: resolution.CreateResolutionRequestStatusWithData([]byte("old content in test branch")), }, { name: "clone: file does not exist", args: ¶ms{ @@ -417,7 +417,7 @@ func TestResolve(t *testing.T) { }, apiToken: "some-token", expectedCommitSHA: commitSHAsInSCMRepo[0], - expectedStatus: internal.CreateResolutionRequestStatusWithData(mainTaskYAML), + expectedStatus: resolution.CreateResolutionRequestStatusWithData(mainTaskYAML), }, { name: "api: successful task", args: ¶ms{ @@ -435,7 +435,7 @@ func TestResolve(t *testing.T) { }, apiToken: "some-token", expectedCommitSHA: commitSHAsInSCMRepo[0], - expectedStatus: internal.CreateResolutionRequestStatusWithData(mainTaskYAML), + expectedStatus: resolution.CreateResolutionRequestStatusWithData(mainTaskYAML), }, { name: "api: successful pipeline", args: ¶ms{ @@ -453,7 +453,7 @@ func TestResolve(t *testing.T) { }, apiToken: "some-token", expectedCommitSHA: commitSHAsInSCMRepo[0], - expectedStatus: internal.CreateResolutionRequestStatusWithData(mainPipelineYAML), + expectedStatus: resolution.CreateResolutionRequestStatusWithData(mainPipelineYAML), }, { name: "api: successful pipeline with default revision", args: ¶ms{ @@ -467,11 +467,11 @@ func TestResolve(t *testing.T) { APISecretNameKey: "token-secret", APISecretKeyKey: "token", APISecretNamespaceKey: system.Namespace(), - defaultRevisionKey: "other", + DefaultRevisionKey: "other", }, apiToken: "some-token", expectedCommitSHA: commitSHAsInSCMRepo[1], - expectedStatus: internal.CreateResolutionRequestStatusWithData(otherPipelineYAML), + expectedStatus: resolution.CreateResolutionRequestStatusWithData(otherPipelineYAML), }, { name: "api: successful override scm type and server URL from user params", @@ -492,7 +492,7 @@ func TestResolve(t *testing.T) { }, apiToken: "some-token", expectedCommitSHA: commitSHAsInSCMRepo[0], - expectedStatus: internal.CreateResolutionRequestStatusWithData(mainTaskYAML), + expectedStatus: resolution.CreateResolutionRequestStatusWithData(mainTaskYAML), }, { name: "api: file does not exist", args: ¶ms{ @@ -509,7 +509,7 @@ func TestResolve(t *testing.T) { APISecretNamespaceKey: system.Namespace(), }, apiToken: "some-token", - expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedStatus: resolution.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", @@ -526,7 +526,7 @@ func TestResolve(t *testing.T) { APISecretKeyKey: "token", APISecretNamespaceKey: system.Namespace(), }, - expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedStatus: resolution.CreateResolutionRequestFailureStatus(), expectedErr: createError("cannot get API token, secret token-secret not found in namespace " + system.Namespace()), }, { name: "api: token secret name not specified", @@ -543,7 +543,7 @@ func TestResolve(t *testing.T) { APISecretNamespaceKey: system.Namespace(), }, apiToken: "some-token", - expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedStatus: resolution.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", @@ -560,7 +560,7 @@ func TestResolve(t *testing.T) { APISecretNamespaceKey: system.Namespace(), }, apiToken: "some-token", - expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedStatus: resolution.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", @@ -576,7 +576,7 @@ func TestResolve(t *testing.T) { APISecretNamespaceKey: system.Namespace(), }, apiToken: "some-token", - expectedStatus: internal.CreateResolutionRequestFailureStatus(), + expectedStatus: resolution.CreateResolutionRequestFailureStatus(), expectedErr: createError("missing or empty scm-type value in configmap"), }} @@ -588,9 +588,9 @@ func TestResolve(t *testing.T) { if cfg == nil { cfg = make(map[string]string) } - cfg[defaultTimeoutKey] = "1m" - if cfg[defaultRevisionKey] == "" { - cfg[defaultRevisionKey] = plumbing.Master.Short() + cfg[DefaultTimeoutKey] = "1m" + if cfg[DefaultRevisionKey] == "" { + cfg[DefaultRevisionKey] = plumbing.Master.Short() } request := createRequest(tc.args) @@ -623,7 +623,7 @@ func TestResolve(t *testing.T) { if expectedStatus.Annotations == nil { expectedStatus.Annotations = make(map[string]string) } - expectedStatus.Annotations[resolutioncommon.AnnotationKeyContentType] = "application/x-yaml" + expectedStatus.Annotations[common.AnnotationKeyContentType] = "application/x-yaml" expectedStatus.Annotations[AnnotationKeyRevision] = tc.expectedCommitSHA expectedStatus.Annotations[AnnotationKeyPath] = tc.args.pathInRepo @@ -819,12 +819,12 @@ func createRequest(args *params) *v1beta1.ResolutionRequest { Namespace: "foo", CreationTimestamp: metav1.Time{Time: time.Now()}, Labels: map[string]string{ - resolutioncommon.LabelKeyResolverType: labelValueGitResolverType, + common.LabelKeyResolverType: labelValueGitResolverType, }, }, Spec: v1beta1.ResolutionRequestSpec{ Params: []pipelinev1.Param{{ - Name: pathParam, + Name: PathParam, Value: *pipelinev1.NewStructuredValues(args.pathInRepo), }}, }, @@ -832,45 +832,45 @@ func createRequest(args *params) *v1beta1.ResolutionRequest { if args.revision != "" { rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ - Name: revisionParam, + Name: RevisionParam, Value: *pipelinev1.NewStructuredValues(args.revision), }) } if args.serverURL != "" { rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ - Name: serverURLParam, + Name: ServerURLParam, Value: *pipelinev1.NewStructuredValues(args.serverURL), }) } if args.scmType != "" { rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ - Name: scmTypeParam, + Name: ScmTypeParam, Value: *pipelinev1.NewStructuredValues(args.scmType), }) } if args.url != "" { rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ - Name: urlParam, + Name: UrlParam, Value: *pipelinev1.NewStructuredValues(args.url), }) } else { rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ - Name: repoParam, + Name: RepoParam, Value: *pipelinev1.NewStructuredValues(args.repo), }) rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ - Name: orgParam, + Name: OrgParam, Value: *pipelinev1.NewStructuredValues(args.org), }) if args.token != "" { rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ - Name: tokenParam, + Name: TokenParam, Value: *pipelinev1.NewStructuredValues(args.token), }) rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ - Name: tokenKeyParam, + Name: TokenKeyParam, Value: *pipelinev1.NewStructuredValues(args.tokenKey), }) } @@ -884,7 +884,7 @@ func resolverDisabledContext() context.Context { } func createError(msg string) error { - return &resolutioncommon.GetResourceError{ + return &common.GetResourceError{ ResolverName: gitResolverName, Key: "foo/rr", Original: errors.New(msg), diff --git a/pkg/resolution/resolver/http/config.go b/pkg/resolution/resolver/http/config.go index 0685fdb07ba..c8ffd8ed545 100644 --- a/pkg/resolution/resolver/http/config.go +++ b/pkg/resolution/resolver/http/config.go @@ -17,7 +17,7 @@ limitations under the License. package http const ( - // timeoutKey is the configuration field name for controlling + // TimeoutKey is the configuration field name for controlling // the maximum duration of a resolution request for a file from http. - timeoutKey = "fetch-timeout" + TimeoutKey = "fetch-timeout" ) diff --git a/pkg/resolution/resolver/http/params.go b/pkg/resolution/resolver/http/params.go index b2e8c9a9c6c..d58008b5942 100644 --- a/pkg/resolution/resolver/http/params.go +++ b/pkg/resolution/resolver/http/params.go @@ -14,15 +14,15 @@ limitations under the License. package http const ( - // urlParam is the URL to fetch the task from - urlParam string = "url" + // UrlParam is the URL to fetch the task from + UrlParam string = "url" - // httpBasicAuthUsername is the user name to use for basic auth - httpBasicAuthUsername string = "http-username" + // HttpBasicAuthUsername is the user name to use for basic auth + HttpBasicAuthUsername string = "http-username" - // httpBasicAuthSecret is the reference to a secret in the PipelineRun or TaskRun namespace to use for basic auth - httpBasicAuthSecret string = "http-password-secret" + // HttpBasicAuthSecret is the reference to a secret in the PipelineRun or TaskRun namespace to use for basic auth + HttpBasicAuthSecret string = "http-password-secret" - // httpBasicAuthSecretKey is the key in the httpBasicAuthSecret secret to use for basic auth - httpBasicAuthSecretKey string = "http-password-secret-key" + // HttpBasicAuthSecretKey is the key in the httpBasicAuthSecret secret to use for basic auth + HttpBasicAuthSecretKey string = "http-password-secret-key" ) diff --git a/pkg/resolution/resolver/http/resolver.go b/pkg/resolution/resolver/http/resolver.go index 49d75bd2dd1..1b9c9e7c921 100644 --- a/pkg/resolution/resolver/http/resolver.go +++ b/pkg/resolution/resolver/http/resolver.go @@ -28,7 +28,7 @@ import ( resolverconfig "github.com/tektoncd/pipeline/pkg/apis/config/resolver" pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" - "github.com/tektoncd/pipeline/pkg/resolution/common" + common "github.com/tektoncd/pipeline/pkg/resolution/common" "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" "go.uber.org/zap" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -89,31 +89,24 @@ 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) } - params, err := populateDefaultParams(ctx, oParams) + params, err := PopulateDefaultParams(ctx, oParams) if err != nil { 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 } @@ -151,7 +144,7 @@ func (rr *resolvedHttpResource) RefSource() *pipelinev1.RefSource { } } -func populateDefaultParams(ctx context.Context, params []pipelinev1.Param) (map[string]string, error) { +func PopulateDefaultParams(ctx context.Context, params []pipelinev1.Param) (map[string]string, error) { paramsMap := make(map[string]string) for _, p := range params { paramsMap[p.Name] = p.Value.StringVal @@ -159,33 +152,33 @@ func populateDefaultParams(ctx context.Context, params []pipelinev1.Param) (map[ var missingParams []string - if _, ok := paramsMap[urlParam]; !ok { - missingParams = append(missingParams, urlParam) + if _, ok := paramsMap[UrlParam]; !ok { + missingParams = append(missingParams, UrlParam) } else { - u, err := url.ParseRequestURI(paramsMap[urlParam]) + u, err := url.ParseRequestURI(paramsMap[UrlParam]) if err != nil { - return nil, fmt.Errorf("cannot parse url %s: %w", paramsMap[urlParam], err) + return nil, fmt.Errorf("cannot parse url %s: %w", paramsMap[UrlParam], err) } if u.Scheme != "http" && u.Scheme != "https" { - return nil, fmt.Errorf("url %s is not a valid http(s) url", paramsMap[urlParam]) + return nil, fmt.Errorf("url %s is not a valid http(s) url", paramsMap[UrlParam]) } } - if username, ok := paramsMap[httpBasicAuthUsername]; ok { - if _, ok := paramsMap[httpBasicAuthSecret]; !ok { - return nil, fmt.Errorf("missing required param %s when using %s", httpBasicAuthSecret, httpBasicAuthUsername) + if username, ok := paramsMap[HttpBasicAuthUsername]; ok { + if _, ok := paramsMap[HttpBasicAuthSecret]; !ok { + return nil, fmt.Errorf("missing required param %s when using %s", HttpBasicAuthSecret, HttpBasicAuthUsername) } if username == "" { - return nil, fmt.Errorf("value %s cannot be empty", httpBasicAuthUsername) + return nil, fmt.Errorf("value %s cannot be empty", HttpBasicAuthUsername) } } - if secret, ok := paramsMap[httpBasicAuthSecret]; ok { - if _, ok := paramsMap[httpBasicAuthUsername]; !ok { - return nil, fmt.Errorf("missing required param %s when using %s", httpBasicAuthUsername, httpBasicAuthSecret) + if secret, ok := paramsMap[HttpBasicAuthSecret]; ok { + if _, ok := paramsMap[HttpBasicAuthUsername]; !ok { + return nil, fmt.Errorf("missing required param %s when using %s", HttpBasicAuthUsername, HttpBasicAuthSecret) } if secret == "" { - return nil, fmt.Errorf("value %s cannot be empty", httpBasicAuthSecret) + return nil, fmt.Errorf("value %s cannot be empty", HttpBasicAuthSecret) } } @@ -199,7 +192,7 @@ func populateDefaultParams(ctx context.Context, params []pipelinev1.Param) (map[ func makeHttpClient(ctx context.Context) (*http.Client, error) { conf := framework.GetResolverConfigFromContext(ctx) timeout, _ := time.ParseDuration(defaultHttpTimeoutValue) - if v, ok := conf[timeoutKey]; ok { + if v, ok := conf[TimeoutKey]; ok { var err error timeout, err = time.ParseDuration(v) if err != nil { @@ -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 @@ -220,8 +213,8 @@ func (r *Resolver) fetchHttpResource(ctx context.Context, params map[string]stri return nil, err } - if targetURL, ok = params[urlParam]; !ok { - return nil, fmt.Errorf("missing required params: %s", urlParam) + if targetURL, ok = params[UrlParam]; !ok { + return nil, fmt.Errorf("missing required params: %s", UrlParam) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) @@ -230,8 +223,8 @@ 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 secret, ok := params[HttpBasicAuthSecret]; ok && secret != "" { + if encodedSecret, err := getBasicAuthSecret(ctx, params, kubeclient, logger); err != nil { return nil, err } else { req.Header.Set("Authorization", encodedSecret) @@ -259,33 +252,44 @@ 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) { - secretName := params[httpBasicAuthSecret] - userName := params[httpBasicAuthUsername] +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 - if v, ok := params[httpBasicAuthSecretKey]; ok { + if v, ok := params[HttpBasicAuthSecretKey]; ok { if v != "" { tokenSecretKey = v } } 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_test.go b/pkg/resolution/resolver/http/resolver_test.go index 630b3882d6e..dddfab29dbf 100644 --- a/pkg/resolution/resolver/http/resolver_test.go +++ b/pkg/resolution/resolver/http/resolver_test.go @@ -33,11 +33,11 @@ import ( 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/internal/resolution" ttesting "github.com/tektoncd/pipeline/pkg/reconciler/testing" - resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + common "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" @@ -68,7 +68,7 @@ const emptyStr = "empty" func TestGetSelector(t *testing.T) { resolver := Resolver{} sel := resolver.GetSelector(context.Background()) - if typ, has := sel[resolutioncommon.LabelKeyResolverType]; !has { + if typ, has := sel[common.LabelKeyResolverType]; !has { t.Fatalf("unexpected selector: %v", sel) } else if typ != LabelValueHttpResolverType { t.Fatalf("unexpected type: %q", typ) @@ -104,7 +104,7 @@ func TestValidateParams(t *testing.T) { resolver := Resolver{} params := map[string]string{} if tc.url != "nourl" { - params[urlParam] = tc.url + params[UrlParam] = tc.url } err := resolver.ValidateParams(contextWithConfig(defaultHttpTimeoutValue), toParams(params)) if tc.expectedErr != nil { @@ -186,7 +186,7 @@ func TestResolve(t *testing.T) { params := []pipelinev1.Param{} if tc.paramSet { params = append(params, pipelinev1.Param{ - Name: urlParam, + Name: UrlParam, Value: *pipelinev1.NewStructuredValues(svr.URL), }) } @@ -253,12 +253,12 @@ func createRequest(params *params) *v1beta1.ResolutionRequest { Namespace: "foo", CreationTimestamp: metav1.Time{Time: time.Now()}, Labels: map[string]string{ - resolutioncommon.LabelKeyResolverType: LabelValueHttpResolverType, + common.LabelKeyResolverType: LabelValueHttpResolverType, }, }, Spec: v1beta1.ResolutionRequestSpec{ Params: []pipelinev1.Param{{ - Name: urlParam, + Name: UrlParam, Value: *pipelinev1.NewStructuredValues(params.url), }}, }, @@ -269,7 +269,7 @@ func createRequest(params *params) *v1beta1.ResolutionRequest { s = "" } rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ - Name: httpBasicAuthSecret, + Name: HttpBasicAuthSecret, Value: *pipelinev1.NewStructuredValues(s), }) } @@ -280,14 +280,14 @@ func createRequest(params *params) *v1beta1.ResolutionRequest { s = "" } rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ - Name: httpBasicAuthUsername, + Name: HttpBasicAuthUsername, Value: *pipelinev1.NewStructuredValues(s), }) } if params.authSecretKey != "" { rr.Spec.Params = append(rr.Spec.Params, pipelinev1.Param{ - Name: httpBasicAuthSecretKey, + Name: HttpBasicAuthSecretKey, Value: *pipelinev1.NewStructuredValues(params.authSecretKey), }) } @@ -309,12 +309,12 @@ func TestResolverReconcileBasicAuth(t *testing.T) { { name: "good/URL Resolution", taskContent: sampleTask, - expectedStatus: internal.CreateResolutionRequestStatusWithData([]byte(sampleTask)), + expectedStatus: resolution.CreateResolutionRequestStatusWithData([]byte(sampleTask)), }, { name: "good/URL Resolution with custom basic auth, and custom secret key", taskContent: sampleTask, - expectedStatus: internal.CreateResolutionRequestStatusWithData([]byte(sampleTask)), + expectedStatus: resolution.CreateResolutionRequestStatusWithData([]byte(sampleTask)), params: ¶ms{ authSecret: "auth-secret", authUsername: "auth", @@ -325,7 +325,7 @@ func TestResolverReconcileBasicAuth(t *testing.T) { { name: "good/URL Resolution with custom basic auth no custom secret key", taskContent: sampleTask, - expectedStatus: internal.CreateResolutionRequestStatusWithData([]byte(sampleTask)), + expectedStatus: resolution.CreateResolutionRequestStatusWithData([]byte(sampleTask)), params: ¶ms{ authSecret: "auth-secret", authUsername: "auth", @@ -510,7 +510,7 @@ func toParams(m map[string]string) []pipelinev1.Param { func contextWithConfig(timeout string) context.Context { config := map[string]string{ - timeoutKey: timeout, + TimeoutKey: timeout, } return framework.InjectResolverConfigToContext(context.Background(), config) } diff --git a/pkg/resolution/resolver/hub/resolver.go b/pkg/resolution/resolver/hub/resolver.go index e94aa390fa5..a4f11726f99 100644 --- a/pkg/resolution/resolver/hub/resolver.go +++ b/pkg/resolution/resolver/hub/resolver.go @@ -27,7 +27,7 @@ import ( goversion "github.com/hashicorp/go-version" resolverconfig "github.com/tektoncd/pipeline/pkg/apis/config/resolver" pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" - "github.com/tektoncd/pipeline/pkg/resolution/common" + common "github.com/tektoncd/pipeline/pkg/resolution/common" "github.com/tektoncd/pipeline/pkg/resolution/resolver/framework" ) @@ -77,7 +77,11 @@ 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 ValidateParams(ctx, params, r.TektonHubURL) +} + +func ValidateParams(ctx context.Context, params []pipelinev1.Param, tektonHubUrl string) error { + if isDisabled(ctx) { return errors.New(disabledError) } @@ -85,7 +89,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, tektonHubUrl); err != nil { return fmt.Errorf("failed to validate params: %w", err) } @@ -110,7 +114,11 @@ 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) { + return Resolve(ctx, params, r.TektonHubURL, r.ArtifactHubURL) +} + +func Resolve(ctx context.Context, params []pipelinev1.Param, tektonHubURL, artifactHubURL string) (framework.ResolvedResource, error) { + if isDisabled(ctx) { return nil, errors.New(disabledError) } @@ -118,12 +126,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, 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, artifactHubURL, tektonHubURL) if err != nil { return nil, err } @@ -139,7 +147,7 @@ func (r *Resolver) Resolve(ctx context.Context, params []pipelinev1.Param) (fram // call hub API switch paramsMap[ParamType] { case ArtifactHubType: - url := fmt.Sprintf(fmt.Sprintf("%s/%s", r.ArtifactHubURL, ArtifactHubYamlEndpoint), + url := fmt.Sprintf(fmt.Sprintf("%s/%s", artifactHubURL, ArtifactHubYamlEndpoint), paramsMap[ParamKind], paramsMap[ParamCatalog], paramsMap[ParamName], paramsMap[ParamVersion]) resp := artifactHubResponse{} if err := fetchHubResource(ctx, url, &resp); err != nil { @@ -150,7 +158,7 @@ func (r *Resolver) Resolve(ctx context.Context, params []pipelinev1.Param) (fram Content: []byte(resp.Data.YAML), }, nil case TektonHubType: - url := fmt.Sprintf(fmt.Sprintf("%s/%s", r.TektonHubURL, TektonHubYamlEndpoint), + url := fmt.Sprintf(fmt.Sprintf("%s/%s", tektonHubURL, TektonHubYamlEndpoint), paramsMap[ParamCatalog], paramsMap[ParamKind], paramsMap[ParamName], paramsMap[ParamVersion]) resp := tektonHubResponse{} if err := fetchHubResource(ctx, url, &resp); err != nil { @@ -198,7 +206,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 +296,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 +408,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 +437,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_test.go b/pkg/resolution/resolver/hub/resolver_test.go index 474838c1a7f..918d3a8d521 100644 --- a/pkg/resolution/resolver/hub/resolver_test.go +++ b/pkg/resolution/resolver/hub/resolver_test.go @@ -29,7 +29,7 @@ import ( "github.com/google/go-cmp/cmp" pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" - resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" + common "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/test/diff" @@ -38,7 +38,7 @@ import ( func TestGetSelector(t *testing.T) { resolver := Resolver{} sel := resolver.GetSelector(context.Background()) - if typ, has := sel[resolutioncommon.LabelKeyResolverType]; !has { + if typ, has := sel[common.LabelKeyResolverType]; !has { t.Fatalf("unexpected selector: %v", sel) } else if typ != LabelValueHubResolverType { t.Fatalf("unexpected type: %q", typ) diff --git a/pkg/resolution/resource/crd_resource.go b/pkg/resolution/resource/crd_resource.go index 90fd7653303..39e41692075 100644 --- a/pkg/resolution/resource/crd_resource.go +++ b/pkg/resolution/resource/crd_resource.go @@ -22,11 +22,11 @@ import ( "errors" "fmt" - pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" "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" + common "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" @@ -63,7 +63,7 @@ func (r *CRDRequester) Submit(ctx context.Context, resolver ResolverName, req Re !apierrors.IsAlreadyExists(err) { return nil, err } - return nil, resolutioncommon.ErrRequestInProgress + return nil, common.ErrRequestInProgress } if rr.Status.GetCondition(apis.ConditionSucceeded).IsUnknown() { @@ -72,53 +72,59 @@ func (r *CRDRequester) Submit(ctx context.Context, resolver ResolverName, req Re // 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 + return nil, common.ErrRequestInProgress } if rr.Status.GetCondition(apis.ConditionSucceeded).IsTrue() { - return crdIntoResource(rr), nil + return CrdIntoResource(rr), nil } message := rr.Status.GetCondition(apis.ConditionSucceeded).GetMessage() - err := resolutioncommon.NewError(resolutioncommon.ReasonResolutionFailed, errors.New(message)) + err := common.NewError(common.ReasonResolutionFailed, errors.New(message)) return nil, err } func (r *CRDRequester) createResolutionRequest(ctx context.Context, resolver ResolverName, req Request) error { + var owner metav1.OwnerReference + if ownedReq, ok := req.(OwnedRequest); ok { + owner = ownedReq.OwnerRef() + } + rr := CreateResolutionRequest(ctx, resolver, req.Name(), req.Namespace(), req.Params(), owner) + _, err := r.clientset.ResolutionV1beta1().ResolutionRequests(rr.Namespace).Create(ctx, rr, metav1.CreateOptions{}) + return err +} + +func CreateResolutionRequest(ctx context.Context, resolver common.ResolverName, name, namespace string, params []v1.Param, ownerRef metav1.OwnerReference) *v1beta1.ResolutionRequest { rr := &v1beta1.ResolutionRequest{ TypeMeta: metav1.TypeMeta{ APIVersion: "resolution.tekton.dev/v1beta1", Kind: "ResolutionRequest", }, ObjectMeta: metav1.ObjectMeta{ - Name: req.Name(), - Namespace: req.Namespace(), + Name: name, + Namespace: namespace, Labels: map[string]string{ - resolutioncommon.LabelKeyResolverType: string(resolver), + common.LabelKeyResolverType: string(resolver), }, }, Spec: v1beta1.ResolutionRequestSpec{ - Params: req.Params(), + Params: params, }, } - appendOwnerReference(rr, req) - _, err := r.clientset.ResolutionV1beta1().ResolutionRequests(rr.Namespace).Create(ctx, rr, metav1.CreateOptions{}) - return err + appendOwnerReference(rr, ownerRef) + return rr } -func appendOwnerReference(rr *v1beta1.ResolutionRequest, req Request) { - 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) +func appendOwnerReference(rr *v1beta1.ResolutionRequest, ownerRef metav1.OwnerReference) { + isOwner := false + for _, ref := range rr.ObjectMeta.OwnerReferences { + if ownerRefsAreEqual(ref, ownerRef) { + isOwner = true } } + if !isOwner { + rr.ObjectMeta.OwnerReferences = append(rr.ObjectMeta.OwnerReferences, ownerRef) + } } func ownerRefsAreEqual(a, b metav1.OwnerReference) bool { @@ -131,21 +137,21 @@ func ownerRefsAreEqual(a, b metav1.OwnerReference) bool { return a.APIVersion == b.APIVersion && a.Kind == b.Kind && a.Name == b.Name && a.UID == b.UID } -// readOnlyResolutionRequest is an opaque wrapper around ResolutionRequest +// ReadOnlyResolutionRequest is an opaque wrapper around ResolutionRequest // that provides the methods needed to read data from it using the // Resource interface without exposing the underlying API // object. -type readOnlyResolutionRequest struct { +type ReadOnlyResolutionRequest struct { req *v1beta1.ResolutionRequest } -var _ ResolvedResource = readOnlyResolutionRequest{} +var _ common.ResolvedResource = ReadOnlyResolutionRequest{} -func crdIntoResource(rr *v1beta1.ResolutionRequest) readOnlyResolutionRequest { - return readOnlyResolutionRequest{req: rr} +func CrdIntoResource(rr *v1beta1.ResolutionRequest) ReadOnlyResolutionRequest { + return ReadOnlyResolutionRequest{req: rr} } -func (r readOnlyResolutionRequest) Annotations() map[string]string { +func (r ReadOnlyResolutionRequest) Annotations() map[string]string { status := r.req.GetStatus() if status != nil && status.Annotations != nil { annotationsCopy := map[string]string{} @@ -157,7 +163,7 @@ func (r readOnlyResolutionRequest) Annotations() map[string]string { return nil } -func (r readOnlyResolutionRequest) Data() ([]byte, error) { +func (r ReadOnlyResolutionRequest) Data() ([]byte, error) { encodedData := r.req.Status.ResolutionRequestStatusFields.Data decodedBytes, err := base64.StdEncoding.Strict().DecodeString(encodedData) if err != nil { @@ -166,6 +172,6 @@ func (r readOnlyResolutionRequest) Data() ([]byte, error) { return decodedBytes, nil } -func (r readOnlyResolutionRequest) RefSource() *pipelinev1.RefSource { +func (r ReadOnlyResolutionRequest) RefSource() *v1.RefSource { return r.req.Status.RefSource } diff --git a/pkg/resolution/resource/crd_resource_test.go b/pkg/resolution/resource/crd_resource_test.go index da5a06fac38..c63a3564977 100644 --- a/pkg/resolution/resource/crd_resource_test.go +++ b/pkg/resolution/resource/crd_resource_test.go @@ -25,10 +25,11 @@ import ( "github.com/google/go-cmp/cmp" "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/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/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 @@ -156,7 +157,7 @@ conditions: inputResolutionRequest: nil, expectedResolutionRequest: createdRR.DeepCopy(), expectedResolvedResource: nil, - expectedErr: resolutioncommon.ErrRequestInProgress, + expectedErr: common.ErrRequestInProgress, }, { name: "resolution request exist and status is unknown", @@ -164,7 +165,7 @@ conditions: inputResolutionRequest: unknownRR.DeepCopy(), expectedResolutionRequest: nil, expectedResolvedResource: nil, - expectedErr: resolutioncommon.ErrRequestInProgress, + expectedErr: common.ErrRequestInProgress, }, { name: "resolution request exist and status is succeeded", @@ -188,7 +189,7 @@ conditions: inputResolutionRequest: failedRR.DeepCopy(), expectedResolutionRequest: nil, expectedResolvedResource: nil, - expectedErr: resolutioncommon.NewError(resolutioncommon.ReasonResolutionFailed, errors.New("error message")), + expectedErr: common.NewError(common.ReasonResolutionFailed, errors.New("error message")), }, } @@ -204,7 +205,7 @@ conditions: ctx := testAssets.Ctx clients := testAssets.Clients - resolver := resolutioncommon.ResolverName("git") + resolver := common.ResolverName("git") crdRequester := resource.NewCRDRequester(clients.ResolutionRequests, testAssets.Informers.ResolutionRequest.Lister()) requestWithOwner := &ownerRequest{ Request: tc.inputRequest.Request(), @@ -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) } @@ -255,7 +256,7 @@ conditions: } type ownerRequest struct { - resolutioncommon.Request + common.Request ownerRef metav1.OwnerReference } @@ -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) } @@ -299,7 +300,7 @@ func mustParseResolutionRequestStatus(t *testing.T, yamlStr string) *v1beta1.Res return output } -func assertResolvedResourceEqual(t *testing.T, expected, actual resolutioncommon.ResolvedResource) { +func assertResolvedResourceEqual(t *testing.T, expected, actual common.ResolvedResource) { t.Helper() expectedBytes, err := expected.Data() if err != nil { diff --git a/pkg/resolution/resource/name.go b/pkg/resolution/resource/name.go index 051eabc89d0..37ec04d46ea 100644 --- a/pkg/resolution/resource/name.go +++ b/pkg/resolution/resource/name.go @@ -23,24 +23,58 @@ import ( "sort" v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" + "knative.dev/pkg/kmeta" ) +// GenerateDeterministicName 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) + ... +func GenerateDeterministicName(prefix, base string, params v1.Params) (string, error) { + return GenerateDeterministicNameFromSpec(prefix, base, &v1beta1.ResolutionRequestSpec{Params: params}) +} + +func GetNameAndNamespace(resolverName string, owner kmeta.OwnerRefable, name string, namespace string, params v1.Params) (string, string, 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 := GenerateDeterministicNameFromSpec(resolverName, remoteResourceBaseName, &v1beta1.ResolutionRequestSpec{Params: params}) + if err != nil { + return "", "", fmt.Errorf("error generating name for taskrun %s/%s: %w", namespace, name, err) + } + return name, namespace, nil +} + // nameHasher returns the hash.Hash to use when generating names. func nameHasher() hash.Hash { return fnv.New128a() } -// GenerateDeterministicName makes a best-effort attempt to create a +// GenerateDeterministicNameFromSpec 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) + ... -func GenerateDeterministicName(prefix, base string, params v1.Params) (string, error) { +func GenerateDeterministicNameFromSpec(prefix, base string, resolutionSpec *v1beta1.ResolutionRequestSpec) (string, error) { hasher := nameHasher() if _, err := hasher.Write([]byte(base)); err != nil { return "", err } + if resolutionSpec == nil { + return fmt.Sprintf("%s-%x", prefix, hasher.Sum(nil)), nil + } + params := resolutionSpec.Params sortedParams := make(v1.Params, len(params)) for i := range params { sortedParams[i] = *params[i].DeepCopy() 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/test/remoteresolution/resolution.go b/test/remoteresolution/resolution.go new file mode 100644 index 00000000000..b08b2760647 --- /dev/null +++ b/test/remoteresolution/resolution.go @@ -0,0 +1,166 @@ +/* +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 test + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/google/go-cmp/cmp" + pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + resource "github.com/tektoncd/pipeline/pkg/remoteresolution/resource" + resolution "github.com/tektoncd/pipeline/pkg/resolution/common" + "github.com/tektoncd/pipeline/test/diff" +) + +var _ resource.Requester = &Requester{} +var _ resolution.ResolvedResource = &ResolvedResource{} + +// NewResolvedResource creates a mock resolved resource that is +// populated with the given data and annotations or returns the given +// error from its Data() method. +func NewResolvedResource(data []byte, annotations map[string]string, source *pipelinev1.RefSource, dataErr error) *ResolvedResource { + return &ResolvedResource{ + ResolvedData: data, + ResolvedAnnotations: annotations, + ResolvedRefSource: source, + DataErr: dataErr, + } +} + +// NewRequester creates a mock requester that resolves to the given +// resource or returns the given error on Submit(). +func NewRequester(resource resolution.ResolvedResource, err error, resolverPayload resource.ResolverPayload) *Requester { + return &Requester{ + ResolvedResource: resource, + SubmitErr: err, + ResolverPayload: resolverPayload, + } +} + +// Requester implements resolution.Requester and makes it easier +// to mock the outcome of a remote pipelineRef or taskRef resolution. +type Requester 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 resource.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 *Requester) Submit(ctx context.Context, resolverName resolution.ResolverName, req resource.Request) (resolution.ResolvedResource, error) { + if (r.ResolverPayload == resource.ResolverPayload{} || r.ResolverPayload.ResolutionSpec == nil || len(r.ResolverPayload.ResolutionSpec.Params) == 0) { + return r.ResolvedResource, r.SubmitErr + } + + reqParams := make(map[string]pipelinev1.ParamValue) + for _, p := range req.ResolverPayload().ResolutionSpec.Params { + reqParams[p.Name] = p.Value + } + + var wrongParams []string + for _, p := range r.ResolverPayload.ResolutionSpec.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 { + // The resolved bytes to return when resolution is complete. + ResolvedData []byte + // An error to return instead of the resolved bytes after + // resolution completes. + DataErr error + // Annotations to return when resolution is complete. + ResolvedAnnotations map[string]string + // ResolvedRefSource to return the source reference of the remote data + ResolvedRefSource *pipelinev1.RefSource +} + +// Data implements resolution.ResolvedResource and returns the mock +// data and/or error given to it on initialization. +func (r *ResolvedResource) Data() ([]byte, error) { + return r.ResolvedData, r.DataErr +} + +// Annotations implements resolution.ResolvedResource and returns +// the mock annotations given to it on initialization. +func (r *ResolvedResource) Annotations() map[string]string { + return r.ResolvedAnnotations +} + +// 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 (r *ResolvedResource) RefSource() *pipelinev1.RefSource { + return r.ResolvedRefSource +} + +// RawRequest stores the raw request data +type RawRequest struct { + ResolverPayload resource.ResolverPayload +} + +// Request returns a Request interface based on the RawRequest. +func (r *RawRequest) Request() resource.Request { + if r == nil { + r = &RawRequest{} + } + return &Request{ + RawRequest: *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 Request struct { + RawRequest +} + +var _ resource.Request = &Request{} + +// NewRequest creates a mock request that is populated with the given name namespace and params +func NewRequest(resolverPayload resource.ResolverPayload) *Request { + return &Request{ + RawRequest: RawRequest{ + ResolverPayload: resolverPayload, + }, + } +} + +// Params implements resolution.Request and returns the mock params given to it on initialization. +func (r *Request) ResolverPayload() resource.ResolverPayload { + return r.RawRequest.ResolverPayload +} + +var _ resource.Request = &Request{} diff --git a/test/resolution.go b/test/resolution/resolution.go similarity index 91% rename from test/resolution.go rename to test/resolution/resolution.go index 514988427f2..b8b940658f9 100644 --- a/test/resolution.go +++ b/test/resolution/resolution.go @@ -24,16 +24,16 @@ import ( "github.com/google/go-cmp/cmp" pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" - resolution "github.com/tektoncd/pipeline/pkg/resolution/common" + common "github.com/tektoncd/pipeline/pkg/resolution/common" "github.com/tektoncd/pipeline/test/diff" ) -var _ resolution.Requester = &Requester{} -var _ resolution.ResolvedResource = &ResolvedResource{} +var _ common.Requester = &Requester{} +var _ common.ResolvedResource = &ResolvedResource{} // NewRequester creates a mock requester that resolves to the given // resource or returns the given error on Submit(). -func NewRequester(resource resolution.ResolvedResource, err error) *Requester { +func NewRequester(resource common.ResolvedResource, err error) *Requester { return &Requester{ ResolvedResource: resource, SubmitErr: err, @@ -57,7 +57,7 @@ func NewResolvedResource(data []byte, annotations map[string]string, source *pip type Requester struct { // The resolved resource object to return when a request is // submitted. - ResolvedResource resolution.ResolvedResource + ResolvedResource common.ResolvedResource // An error to return when a request is submitted. SubmitErr error // Params that should match those on the request in order to return the resolved resource @@ -67,7 +67,7 @@ type Requester struct { // 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 *Requester) Submit(ctx context.Context, resolverName resolution.ResolverName, req resolution.Request) (resolution.ResolvedResource, error) { +func (r *Requester) Submit(ctx context.Context, resolverName common.ResolverName, req common.Request) (common.ResolvedResource, error) { if len(r.Params) == 0 { return r.ResolvedResource, r.SubmitErr } @@ -134,7 +134,7 @@ type RawRequest struct { } // Request returns a Request interface based on the RawRequest. -func (r *RawRequest) Request() resolution.Request { +func (r *RawRequest) Request() common.Request { if r == nil { r = &RawRequest{} } @@ -149,7 +149,7 @@ type Request struct { RawRequest } -var _ resolution.Request = &Request{} +var _ common.Request = &Request{} // NewRequest creates a mock request that is populated with the given name namespace and params func NewRequest(name, namespace string, params []pipelinev1.Param) *Request { diff --git a/test/resolvers_test.go b/test/resolvers_test.go index 1979a1703c2..f7a6e3111ed 100644 --- a/test/resolvers_test.go +++ b/test/resolvers_test.go @@ -37,7 +37,7 @@ import ( resolverconfig "github.com/tektoncd/pipeline/pkg/apis/config/resolver" v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" "github.com/tektoncd/pipeline/pkg/reconciler/pipelinerun" - "github.com/tektoncd/pipeline/pkg/resolution/resolver/git" + "github.com/tektoncd/pipeline/pkg/remoteresolution/resolver/git" "github.com/tektoncd/pipeline/test/parse" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"