Skip to content

Commit

Permalink
Add credentials to HTTP resolver
Browse files Browse the repository at this point in the history
This adds the ability to pass credentials to the HTTP resolver when
fetching the URL.

This let's for example to fetch tasks from SCM private repositories on
other SCM providers than configured with the git resolver.

Fixes #7296

Signed-off-by: Chmouel Boudjnah <chmouel@redhat.com>
  • Loading branch information
chmouel committed Nov 7, 2023
1 parent 515c4a3 commit 64d3ee4
Show file tree
Hide file tree
Showing 7 changed files with 432 additions and 27 deletions.
4 changes: 4 additions & 0 deletions docs/git-resolver.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ Note that not all `go-scm` implementations have been tested with the `git` resol
* BitBucket Server
* BitBucket Cloud

Fetching from multiple Git providers with different configuration is not
supported. You can use the [http resolver](./http-resolver.md) to fetch URL
from another provider with different credentials.

#### Task Resolution

```yaml
Expand Down
30 changes: 27 additions & 3 deletions docs/http-resolver.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ This resolver responds to type `http`.

## Parameters

| Param Name | Description | Example Value |
|------------------|-------------------------------------------------------------------------------|------------------------------------------------------------|
| `url` | The URL to fetch from | https://raw.githubusercontent.com/tektoncd-catalog/git-clone/main/task/git-clone/git-clone.yaml |
| Param Name | Description | Example Value | |
|----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|---|
| `url` | The URL to fetch from | https://raw.githubusercontent.com/tektoncd-catalog/git-clone/main/task/git-clone/git-clone.yaml | |
| `http-username` | An optional username when fetching a task with credentials (need to be used in conjunction with `http-password-secret`) | `git` | |
| `http-password-secret` | An optional secret in the PipelineRun directory with a reference to a password when fetching a task with credentials (need to be used in conjunction with `http-username`) | `http-password` | |
| `http-password-secret-key` | An optional key in the `http-password-secret` to be used when fetching a task with credentials | Default: `password` | |

A valid URL must be provided. Only HTTP or HTTPS URLs are supported.

Expand Down Expand Up @@ -54,6 +57,27 @@ spec:
value: https://raw.githubusercontent.com/tektoncd-catalog/git-clone/main/task/git-clone/git-clone.yaml
```

### Task Resolution with Basic Auth

```yaml
apiVersion: tekton.dev/v1beta1
kind: TaskRun
metadata:
name: remote-task-reference
spec:
taskRef:
resolver: http
params:
- name: url
value: https://raw.githubusercontent.com/owner/private-repo/main/task/task.yaml
- name: http-username
value: git
- name: http-password-secret
value: git-secret
- name: http-password-secret-key
value: git-token
```

### Pipeline Resolution

```yaml
Expand Down
35 changes: 35 additions & 0 deletions examples/v1/pipelineruns/beta/http-resolver-credentials.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# This http resolver example will uses a username and password to access the
# URL.
#
# http-password-secret is a Kubernetes secret containing the
# password in the same namespace where this PipelineRun runs.
---
kind: Secret
apiVersion: v1
metadata:
name: my-secret
stringData:
token: "token"
---
apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
generateName: http-resolver-
spec:
pipelineSpec:
tasks:
- name: http-resolver
taskRef:
resolver: http
params:
- name: url
value: https://api.hub.tekton.dev/v1/resource/tekton/task/tkn/0.4/raw
- name: http-username
value: git
- name: http-password-secret
value: my-secret
- name: http-password-secret-key
value: token
params:
- name: ARGS
value: ["version"]
6 changes: 3 additions & 3 deletions pkg/resolution/resolver/framework/testing/fakecontroller.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,14 @@ func RunResolverReconcileTest(ctx context.Context, t *testing.T, d test.Data, re
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)
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)
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)
t.Fatalf("did not expect an error, but got `%v`", err)
}
}

Expand Down
13 changes: 11 additions & 2 deletions pkg/resolution/resolver/http/params.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ limitations under the License.
package http

const (
// urlParam is the url to fetch the task from
urlParam string = "url"
// urlParamType is the URL to fetch the task from
urlParamType string = "url"

// httpBasicAuthUsername is the user name to use for basic auth
httpBasicAuthUsername string = "http-username"

// httpBasicAuthSecret is the reference to a secret in the PipelineRun namespace to use for basic auth
httpBasicAuthSecret string = "http-password-secret"

// httpBasicAuthSecretKey is the key in the httpBasicAuthSecret secret to use for basic auth
httpBasicAuthSecretKey string = "http-password-secret-key"
)
97 changes: 85 additions & 12 deletions pkg/resolution/resolver/http/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package http
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
Expand All @@ -29,6 +30,12 @@ import (
pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1"
"github.com/tektoncd/pipeline/pkg/resolution/common"
"github.com/tektoncd/pipeline/pkg/resolution/resolver/framework"
"go.uber.org/zap"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
kubeclient "knative.dev/pkg/client/injection/kube/client"
"knative.dev/pkg/logging"
)

const (
Expand All @@ -41,17 +48,25 @@ const (
// httpResolverName The name of the resolver
httpResolverName = "Http"

// ConfigMapName is the http resolver's config map
// configMapName is the http resolver's config map
configMapName = "http-resolver-config"

// default Timeout value when fetching http resources in seconds
defaultHttpTimeoutValue = "1m"

// default key in the HTTP password secret
defaultBasicAuthSecretKey = "password"
)

// Resolver implements a framework.Resolver that can fetch files from an HTTP URL
type Resolver struct{}
type Resolver struct {
kubeClient kubernetes.Interface
logger *zap.SugaredLogger
}

func (r *Resolver) Initialize(context.Context) error {
func (r *Resolver) Initialize(ctx context.Context) error {
r.kubeClient = kubeclient.Get(ctx)
r.logger = logging.FromContext(ctx)
return nil
}

Expand Down Expand Up @@ -95,7 +110,7 @@ func (r *Resolver) Resolve(ctx context.Context, oParams []pipelinev1.Param) (fra
return nil, err
}

return fetchHttpResource(ctx, params)
return r.fetchHttpResource(ctx, params)
}

func (r *Resolver) isDisabled(ctx context.Context) bool {
Expand Down Expand Up @@ -144,15 +159,33 @@ func populateDefaultParams(ctx context.Context, params []pipelinev1.Param) (map[

var missingParams []string

if _, ok := paramsMap[urlParam]; !ok {
missingParams = append(missingParams, urlParam)
if _, ok := paramsMap[urlParamType]; !ok {
missingParams = append(missingParams, urlParamType)
} else {
u, err := url.ParseRequestURI(paramsMap[urlParam])
u, err := url.ParseRequestURI(paramsMap[urlParamType])
if err != nil {
return nil, fmt.Errorf("cannot parse url %s: %w", paramsMap[urlParam], err)
return nil, fmt.Errorf("cannot parse url %s: %w", paramsMap[urlParamType], err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("url %s is not a valid http(s) url", paramsMap[urlParam])
return nil, fmt.Errorf("url %s is not a valid http(s) url", paramsMap[urlParamType])
}
}

if username, ok := paramsMap[httpBasicAuthUsername]; ok {
if _, ok := paramsMap[httpBasicAuthSecret]; !ok {
return nil, fmt.Errorf("missing required param %s when using %s", httpBasicAuthSecret, httpBasicAuthUsername)
}
if username == "" {
return nil, fmt.Errorf("value %s cannot be empty", httpBasicAuthUsername)
}
}

if secret, ok := paramsMap[httpBasicAuthSecret]; ok {
if _, ok := paramsMap[httpBasicAuthUsername]; !ok {
return nil, fmt.Errorf("missing required param %s when using %s", httpBasicAuthUsername, httpBasicAuthSecret)
}
if secret == "" {
return nil, fmt.Errorf("value %s cannot be empty", httpBasicAuthSecret)
}
}

Expand All @@ -178,7 +211,7 @@ func makeHttpClient(ctx context.Context) (*http.Client, error) {
}, nil
}

func fetchHttpResource(ctx context.Context, params map[string]string) (framework.ResolvedResource, error) {
func (r *Resolver) fetchHttpResource(ctx context.Context, params map[string]string) (framework.ResolvedResource, error) {
var targetURL string
var ok bool

Expand All @@ -187,15 +220,24 @@ func fetchHttpResource(ctx context.Context, params map[string]string) (framework
return nil, err
}

if targetURL, ok = params[urlParam]; !ok {
return nil, fmt.Errorf("missing required params: %s", urlParam)
if targetURL, ok = params[urlParamType]; !ok {
return nil, fmt.Errorf("missing required params: %s", urlParamType)
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
if err != nil {
return nil, fmt.Errorf("constructing request: %w", err)
}

// 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 {
return nil, err
} else {
req.Header.Set("Authorization", encodedSecret)
}
}

resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("error fetching URL: %w", err)
Expand All @@ -216,3 +258,34 @@ func fetchHttpResource(ctx context.Context, params map[string]string) (framework
URL: targetURL,
}, nil
}

func (r *Resolver) getBasicAuthSecret(ctx context.Context, params map[string]string) (string, error) {
secretName := params[httpBasicAuthSecret]
userName := params[httpBasicAuthUsername]
tokenSecretKey := defaultBasicAuthSecretKey
if v, ok := params[httpBasicAuthSecretKey]; ok {
if v != "" {
tokenSecretKey = v
}
}
secretNS := common.RequestNamespace(ctx)
secret, err := r.kubeClient.CoreV1().Secrets(secretNS).Get(ctx, secretName, metav1.GetOptions{})
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)
return "", notFoundErr
}
wrappedErr := fmt.Errorf("error reading API token from secret %s in namespace %s: %w", secretName, secretNS, err)
r.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)
return "", err
}
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString(
[]byte(fmt.Sprintf("%s:%s", userName, secretVal)))), nil
}
Loading

0 comments on commit 64d3ee4

Please sign in to comment.