Repository: kubesphere/ks-devops
Pull request #388 (2021-11-29 13:22:29)

[release-3.2] Fix that the controller did not use creator's token to trigger a Jenkins job
This is an automated cherry-pick of #384

ks-ci-bot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubesphere/ks-devops/pull/388#" title="Author self-approved">ks-ci-bot</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **linuxsuren** after the PR has been reviewed.
You can assign the PR to them by writing `/assign @linuxsuren` in a comment when ready.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubesphere%2Fks-devops).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kubesphere/ks-devops/blob/release-3.2/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["linuxsuren"]} -->
JohnNiang: /label tide/merge-method-squash
ks-ci-bot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubesphere/ks-devops/pull/388#" title="Author self-approved">ks-ci-bot</a>*, *<a href="https://github.com/kubesphere/ks-devops/pull/388#pullrequestreview-817796657" title="Approved">LinuxSuRen</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubesphere%2Fks-devops).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/kubesphere/ks-devops/blob/release-3.2/OWNERS)~~ [LinuxSuRen]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
JohnNiang: /lgtm

Patch:
diff --git a/.golangci.yml b/.golangci.yml
index 46401d3..0a4166a 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -36,8 +36,8 @@ run:
     - pkg/api/types.go
     - pkg/apiserver/apiserver.go
     - pkg/apiserver/authentication/oauth/oauth_options.go
-    - pkg/apiserver/authentication/token/issuer.go
-    - pkg/apiserver/authentication/token/jwt.go
+    - pkg/jwt/token/issuer.go
+    - pkg/jwt/token/jwt.go
     - pkg/apiserver/filters/requestinfo.go
     - pkg/apiserver/query/types.go
     - pkg/apiserver/request/context.go
diff --git a/cmd/controller/app/controllers.go b/cmd/controller/app/controllers.go
index 55dbbb5..08b7618 100644
--- a/cmd/controller/app/controllers.go
+++ b/cmd/controller/app/controllers.go
@@ -17,6 +17,7 @@ limitations under the License.
 package app
 
 import (
+	"kubesphere.io/devops/pkg/jwt/token"
 	"kubesphere.io/devops/pkg/server/errors"
 
 	"github.com/jenkins-zh/jenkins-client/pkg/core"
@@ -44,12 +45,14 @@ func addControllers(mgr manager.Manager, client k8s.Client, informerFactory info
 		return errors.New("devopsClient should not be nil")
 	}
 
+	tokenIssuer := token.NewTokenIssuer(s.JWTOptions.Secret, s.JWTOptions.MaximumClockSkew)
 	// add PipelineRun controller
 	if err := (&pipelinerun.Reconciler{
 		Client:       mgr.GetClient(),
 		Scheme:       mgr.GetScheme(),
 		DevOpsClient: devopsClient,
 		JenkinsCore:  jenkinsCore,
+		TokenIssuer:  tokenIssuer,
 	}).SetupWithManager(mgr); err != nil {
 		klog.Errorf("unable to create pipelinerun-controller, err: %v", err)
 		return err
diff --git a/cmd/controller/app/options/jwt.go b/cmd/controller/app/options/jwt.go
new file mode 100644
index 0000000..4c17d66
--- /dev/null
+++ b/cmd/controller/app/options/jwt.go
@@ -0,0 +1,11 @@
+package options
+
+import "time"
+
+// JWTOptions contain some options of JWT, such as secret and clock skew.
+type JWTOptions struct {
+	// MaximumClockSkew indicates token verification maximum time difference.
+	MaximumClockSkew time.Duration
+	// Secret is used to sign JWT token.
+	Secret string
+}
diff --git a/cmd/controller/app/options/options.go b/cmd/controller/app/options/options.go
index b1efcb7..861bc1a 100644
--- a/cmd/controller/app/options/options.go
+++ b/cmd/controller/app/options/options.go
@@ -41,6 +41,7 @@ type DevOpsControllerManagerOptions struct {
 	WebhookCertDir    string
 	S3Options         *s3.Options
 	FeatureOptions    *FeatureOptions
+	JWTOptions        *JWTOptions
 
 	// KubeSphere is using sigs.k8s.io/application as fundamental object to implement Application Management.
 	// There are other projects also built on sigs.k8s.io/application, when KubeSphere installed along side
diff --git a/cmd/controller/app/server.go b/cmd/controller/app/server.go
index f66c07f..ec48df3 100644
--- a/cmd/controller/app/server.go
+++ b/cmd/controller/app/server.go
@@ -18,6 +18,7 @@ package app
 
 import (
 	"fmt"
+
 	"github.com/jenkins-zh/jenkins-client/pkg/core"
 	utilerrors "k8s.io/apimachinery/pkg/util/errors"
 	"kubesphere.io/devops/cmd/controller/app/options"
@@ -51,10 +52,14 @@ func NewControllerManagerCommand() *cobra.Command {
 			KubernetesOptions: conf.KubernetesOptions,
 			JenkinsOptions:    conf.JenkinsOptions,
 			S3Options:         conf.S3Options,
-			FeatureOptions:    s.FeatureOptions,
-			LeaderElection:    s.LeaderElection,
-			LeaderElect:       s.LeaderElect,
-			WebhookCertDir:    s.WebhookCertDir,
+			JWTOptions: &options.JWTOptions{
+				Secret:           conf.AuthenticationOptions.JwtSecret,
+				MaximumClockSkew: conf.AuthenticationOptions.MaximumClockSkew,
+			},
+			FeatureOptions: s.FeatureOptions,
+			LeaderElection: s.LeaderElection,
+			LeaderElect:    s.LeaderElect,
+			WebhookCertDir: s.WebhookCertDir,
 		}
 	} else {
 		klog.Fatal("Failed to load configuration from disk", err)
diff --git a/cmd/tools/jwt/app/jwt_root.go b/cmd/tools/jwt/app/jwt_root.go
index 9dfe799..3bc5b23 100644
--- a/cmd/tools/jwt/app/jwt_root.go
+++ b/cmd/tools/jwt/app/jwt_root.go
@@ -3,15 +3,16 @@ package app
 import (
 	"context"
 	"fmt"
+	"math/rand"
+	"strings"
+	"time"
+
 	"github.com/spf13/cobra"
 	"gopkg.in/yaml.v2"
 	"k8s.io/apiserver/pkg/authentication/user"
 	"k8s.io/client-go/kubernetes"
-	"kubesphere.io/devops/pkg/apiserver/authentication/token"
 	"kubesphere.io/devops/pkg/config"
-	"math/rand"
-	"strings"
-	"time"
+	"kubesphere.io/devops/pkg/jwt/token"
 )
 
 // NewCmd creates a root command for jwt
diff --git a/controllers/jenkins/pipelinerun/pipelinerun_controller.go b/controllers/jenkins/pipelinerun/pipelinerun_controller.go
index 1d69581..cabf7d6 100644
--- a/controllers/jenkins/pipelinerun/pipelinerun_controller.go
+++ b/controllers/jenkins/pipelinerun/pipelinerun_controller.go
@@ -31,16 +31,21 @@ import (
 	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/util/validation"
+	"k8s.io/apiserver/pkg/authentication/user"
 	"k8s.io/client-go/tools/record"
 	"k8s.io/client-go/util/retry"
 	"k8s.io/klog"
 	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
 	devopsClient "kubesphere.io/devops/pkg/client/devops"
+	"kubesphere.io/devops/pkg/jwt/token"
 	"kubesphere.io/devops/pkg/utils/sliceutil"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 )
 
+// tokenExpireIn indicates that the temporary token issued by controller will be expired in some time.
+const tokenExpireIn time.Duration = 5 * time.Minute
+
 // Reconciler reconciles a PipelineRun object
 type Reconciler struct {
 	client.Client
@@ -48,6 +53,7 @@ type Reconciler struct {
 	Scheme       *runtime.Scheme
 	DevOpsClient devopsClient.Interface
 	JenkinsCore  core.JenkinsCore
+	TokenIssuer  token.Issuer
 	recorder     record.EventRecorder
 }
 
@@ -172,11 +178,19 @@ func (r *Reconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
 		return ctrl.Result{RequeueAfter: 3 * time.Second}, nil
 	}
 
+	// get or create JenkinsCore if the PipelineRun has creator annotation
+	jenkinsCore, err := r.getOrCreateJenkinsCore(pipelineRunCopied.GetAnnotations())
+	if err != nil {
+		r.recorder.Eventf(pipelineRunCopied, corev1.EventTypeWarning, v1alpha3.TriggerFailed, "Failed to trigger PipelineRun %s, and error was %v", req.NamespacedName, err)
+		return ctrl.Result{}, err
+	}
+	// create trigger handler
+	triggerHandler := &jenkinsHandler{jenkinsCore}
 	// first run
-	jobRun, err := jHandler.triggerJenkinsJob(namespaceName, pipelineName, &pipelineRunCopied.Spec)
+	jobRun, err := triggerHandler.triggerJenkinsJob(namespaceName, pipelineName, &pipelineRunCopied.Spec)
 	if err != nil {
 		log.Error(err, "unable to run pipeline", "namespace", namespaceName, "pipeline", pipeline.Name)
-		r.recorder.Eventf(pipelineRunCopied, corev1.EventTypeWarning, v1alpha3.TriggerFailed, "Failed to trigger PipelineRun %s, and error was %s", req.NamespacedName, err)
+		r.recorder.Eventf(pipelineRunCopied, corev1.EventTypeWarning, v1alpha3.TriggerFailed, "Failed to trigger PipelineRun %s, and error was %v", req.NamespacedName, err)
 		return ctrl.Result{}, err
 	}
 	// check if there is still a same PipelineRun
@@ -309,6 +323,24 @@ func (r *Reconciler) makePipelineRunOrphan(ctx context.Context, pr *v1alpha3.Pip
 	return r.updateStatus(ctx, &pr.Status, client.ObjectKey{Namespace: pr.Namespace, Name: pr.Name})
 }
 
+func (r *Reconciler) getOrCreateJenkinsCore(annotations map[string]string) (*core.JenkinsCore, error) {
+	creator, ok := annotations[v1alpha3.PipelineRunCreatorAnnoKey]
+	if !ok || creator == "" {
+		return &r.JenkinsCore, nil
+	}
+	// create a new JenkinsCore for current creator
+	accessToken, err := r.TokenIssuer.IssueTo(&user.DefaultInfo{Name: creator}, token.AccessToken, tokenExpireIn)
+	if err != nil {
+		return nil, fmt.Errorf("failed to issue access token for creator %s, error was %v", creator, err)
+	}
+	jenkinsCore := &core.JenkinsCore{
+		URL:      r.JenkinsCore.URL,
+		UserName: creator,
+		Token:    accessToken,
+	}
+	return jenkinsCore, nil
+}
+
 // SetupWithManager sets up the controller with the Manager.
 func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
 	// the name should obey Kubernetes naming convention: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/
diff --git a/pkg/api/devops/v1alpha3/groupversion_info.go b/pkg/api/devops/v1alpha3/groupversion_info.go
index 943c191..4336187 100644
--- a/pkg/api/devops/v1alpha3/groupversion_info.go
+++ b/pkg/api/devops/v1alpha3/groupversion_info.go
@@ -37,6 +37,8 @@ const (
 	PipelineNameLabelKey = GroupName + "/pipeline"
 	// SCMRefNameLabelKey is label key of SCM reference name.
 	SCMRefNameLabelKey = GroupName + "/scm-ref-name"
+	// PipelineRunCreatorAnnoKey is annotation key of PipelineRun's creator
+	PipelineRunCreatorAnnoKey = GroupName + "/creator"
 )
 
 var (
diff --git a/pkg/apiserver/authentication/authenticators/bearertoken/bearertoken.go b/pkg/apiserver/authentication/authenticators/bearertoken/bearertoken.go
index 03430d5..b68ea0c 100644
--- a/pkg/apiserver/authentication/authenticators/bearertoken/bearertoken.go
+++ b/pkg/apiserver/authentication/authenticators/bearertoken/bearertoken.go
@@ -2,11 +2,12 @@ package bearertoken
 
 import (
 	"context"
-	"k8s.io/apiserver/pkg/authentication/user"
 	"time"
 
+	"k8s.io/apiserver/pkg/authentication/user"
+
 	"k8s.io/apiserver/pkg/authentication/authenticator"
-	jwt "kubesphere.io/devops/pkg/apiserver/authentication/token"
+	jwt "kubesphere.io/devops/pkg/jwt/token"
 )
 
 // tokenAuthenticator implements an simple auth which only check the format of target JWT token
diff --git a/pkg/apiserver/authentication/token/issuer.go b/pkg/jwt/token/issuer.go
similarity index 100%
rename from pkg/apiserver/authentication/token/issuer.go
rename to pkg/jwt/token/issuer.go
diff --git a/pkg/apiserver/authentication/token/jwt.go b/pkg/jwt/token/jwt.go
similarity index 99%
rename from pkg/apiserver/authentication/token/jwt.go
rename to pkg/jwt/token/jwt.go
index f62b1b1..b4aaeef 100644
--- a/pkg/apiserver/authentication/token/jwt.go
+++ b/pkg/jwt/token/jwt.go
@@ -18,9 +18,10 @@ package token
 
 import (
 	"fmt"
-	"kubesphere.io/devops/pkg/server/errors"
 	"time"
 
+	"kubesphere.io/devops/pkg/server/errors"
+
 	"github.com/form3tech-oss/jwt-go"
 	"k8s.io/apiserver/pkg/authentication/user"
 	"k8s.io/klog"
diff --git a/pkg/apiserver/authentication/token/jwt_test.go b/pkg/jwt/token/jwt_test.go
similarity index 100%
rename from pkg/apiserver/authentication/token/jwt_test.go
rename to pkg/jwt/token/jwt_test.go
diff --git a/pkg/kapis/devops/v1alpha3/pipelinerun/handler.go b/pkg/kapis/devops/v1alpha3/pipelinerun/handler.go
index 46ac021..cc47d90 100644
--- a/pkg/kapis/devops/v1alpha3/pipelinerun/handler.go
+++ b/pkg/kapis/devops/v1alpha3/pipelinerun/handler.go
@@ -3,6 +3,7 @@ package pipelinerun
 import (
 	"context"
 	"encoding/json"
+	"fmt"
 	"io"
 	"strconv"
 
@@ -12,6 +13,7 @@ import (
 	"kubesphere.io/devops/pkg/api"
 	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
 	"kubesphere.io/devops/pkg/apiserver/query"
+	apiserverrequest "kubesphere.io/devops/pkg/apiserver/request"
 	"kubesphere.io/devops/pkg/client/devops"
 	"kubesphere.io/devops/pkg/models/pipelinerun"
 	resourcesV1alpha3 "kubesphere.io/devops/pkg/models/resources/v1alpha3"
@@ -130,8 +132,19 @@ func (h *apiHandler) createPipelineRun(request *restful.Request, response *restf
 		return
 	}
 
+	// get current login user from request context
+	user, ok := apiserverrequest.UserFrom(request.Request.Context())
+	if !ok || user == nil {
+		// should never happen
+		err := fmt.Errorf("unauthenticated user entered to create PipelineRun for Pipeline '%s/%s'", nsName, pipName)
+		api.HandleUnauthorized(response, request, err)
+		return
+	}
 	// create PipelineRun
 	pr := CreatePipelineRun(&pipeline, &payload, scm)
+	if user != nil && user.GetName() != "" {
+		pr.GetAnnotations()[v1alpha3.PipelineRunCreatorAnnoKey] = user.GetName()
+	}
 	if err := h.client.Create(context.Background(), pr); err != nil {
 		api.HandleError(request, response, err)
 		return
diff --git a/pkg/models/auth/token.go b/pkg/models/auth/token.go
index cd8806f..a9bfd24 100644
--- a/pkg/models/auth/token.go
+++ b/pkg/models/auth/token.go
@@ -28,8 +28,8 @@ import (
 
 	"kubesphere.io/devops/pkg/apiserver/authentication/oauth"
 	authoptions "kubesphere.io/devops/pkg/apiserver/authentication/options"
-	"kubesphere.io/devops/pkg/apiserver/authentication/token"
 	"kubesphere.io/devops/pkg/client/cache"
+	"kubesphere.io/devops/pkg/jwt/token"
 )
 
 type TokenManagementInterface interface {


Test patch:
diff --git a/controllers/jenkins/pipelinerun/pipelinerun_controller_test.go b/controllers/jenkins/pipelinerun/pipelinerun_controller_test.go
index f7ad8cd..633fe07 100644
--- a/controllers/jenkins/pipelinerun/pipelinerun_controller_test.go
+++ b/controllers/jenkins/pipelinerun/pipelinerun_controller_test.go
@@ -1,14 +1,19 @@
 package pipelinerun
 
 import (
+	"reflect"
 	"testing"
 
+	"github.com/jenkins-zh/jenkins-client/pkg/core"
 	"github.com/jenkins-zh/jenkins-client/pkg/job"
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/gomega"
+	"github.com/stretchr/testify/assert"
 	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apiserver/pkg/authentication/user"
 	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
 	"kubesphere.io/devops/pkg/client/clientset/versioned/scheme"
+	"kubesphere.io/devops/pkg/jwt/token"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	// nolint
@@ -257,3 +262,84 @@ var _ = Describe("TestReconciler_hasSamePipelineRun", func() {
 		})
 	})
 })
+
+func TestReconciler_getOrCreateJenkinsCoreIfHasCreator(t *testing.T) {
+	defaultJenkinsCore := &core.JenkinsCore{
+		URL:      "https://devops.com",
+		UserName: "admin",
+		Token:    "fake-token",
+	}
+	tokenIssuer := token.NewTokenIssuer("test-secret", 0)
+	accessToken, err := tokenIssuer.IssueTo(&user.DefaultInfo{Name: "tester"}, token.AccessToken, tokenExpireIn)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	type fields struct {
+		JenkinsCore core.JenkinsCore
+		TokenIssuer token.Issuer
+	}
+	type args struct {
+		annotations map[string]string
+	}
+	tests := []struct {
+		name      string
+		fields    fields
+		args      args
+		want      *core.JenkinsCore
+		assertion func(*core.JenkinsCore)
+		wantErr   bool
+	}{{
+		name: "Empty annotations",
+		fields: fields{
+			JenkinsCore: *defaultJenkinsCore,
+			TokenIssuer: tokenIssuer,
+		},
+		want: defaultJenkinsCore,
+	}, {
+		name: "Has creator in annotations",
+		fields: fields{
+			JenkinsCore: *defaultJenkinsCore,
+			TokenIssuer: tokenIssuer,
+		},
+		args: args{
+			annotations: map[string]string{
+				v1alpha3.PipelineRunCreatorAnnoKey: "tester",
+			},
+		},
+		want: &core.JenkinsCore{
+			URL:      defaultJenkinsCore.URL,
+			UserName: "tester",
+			Token:    accessToken,
+		},
+		assertion: func(jenkinsCore *core.JenkinsCore) {
+			assert.Equal(t, "tester", jenkinsCore.UserName)
+			assert.Equal(t, defaultJenkinsCore.URL, jenkinsCore.URL)
+			userInfo, tokenType, err := tokenIssuer.Verify(jenkinsCore.Token)
+			assert.Equal(t, &user.DefaultInfo{Name: "tester"}, userInfo)
+			assert.Equal(t, token.AccessToken, tokenType)
+			assert.Nil(t, err)
+		},
+	},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := &Reconciler{
+				JenkinsCore: tt.fields.JenkinsCore,
+				TokenIssuer: tt.fields.TokenIssuer,
+			}
+			got, err := r.getOrCreateJenkinsCore(tt.args.annotations)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("Reconciler.getOrCreateJenkinsCoreIfHasCreator() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if tt.assertion != nil {
+				tt.assertion(got)
+				return
+			}
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("Reconciler.getOrCreateJenkinsCoreIfHasCreator() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}