diff --git a/cmd/controller/app/controllers.go b/cmd/controller/app/controllers.go index f508da1e..f291538f 100644 --- a/cmd/controller/app/controllers.go +++ b/cmd/controller/app/controllers.go @@ -74,7 +74,7 @@ func addControllers(mgr manager.Manager, client k8s.Client, informerFactory info }).SetupWithManager(mgr); err != nil { return err } - reconcilers := getAllControllers(mgr, client, informerFactory, devopsClient, s) + reconcilers := getAllControllers(mgr, client, informerFactory, devopsClient, s, jenkinsCore) // Add all controllers into manager. for name, ok := range s.FeatureOptions.GetControllers() { @@ -93,7 +93,7 @@ func addControllers(mgr manager.Manager, client k8s.Client, informerFactory info } func getAllControllers(mgr manager.Manager, client k8s.Client, informerFactory informers.InformerFactory, - devopsClient devops.Interface, s *options.DevOpsControllerManagerOptions) map[string]func(mgr manager.Manager) error { + devopsClient devops.Interface, s *options.DevOpsControllerManagerOptions, jenkinsCore core.JenkinsCore) map[string]func(mgr manager.Manager) error { argocdReconciler := &argocd.Reconciler{ Client: mgr.GetClient(), @@ -163,6 +163,16 @@ func getAllControllers(mgr manager.Manager, client k8s.Client, informerFactory i informerFactory.KubernetesSharedInformerFactory().Core().V1().Namespaces(), informerFactory.KubeSphereSharedInformerFactory().Devops().V1alpha3().Pipelines())) } + + tokenIssuer := token.NewTokenIssuer(s.JWTOptions.Secret, s.JWTOptions.MaximumClockSkew) + if err == nil { + jenkinsfileReconciler := &jenkinspipeline.JenkinsfileReconciler{ + Client: mgr.GetClient(), + TokenIssuer: tokenIssuer, + JenkinsCore: jenkinsCore, + } + err = jenkinsfileReconciler.SetupWithManager(mgr) + } return err }, argocdReconciler.GetGroupName(): func(mgr manager.Manager) (err error) { diff --git a/controllers/jenkins/pipeline/constants.go b/controllers/jenkins/pipeline/constants.go new file mode 100644 index 00000000..bdd040a5 --- /dev/null +++ b/controllers/jenkins/pipeline/constants.go @@ -0,0 +1,22 @@ +/* +Copyright 2022 The KubeSphere Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pipeline + +const ( + // ControllerGroupName is the group name of a set of controllers + ControllerGroupName = "jenkins" +) diff --git a/controllers/jenkins/pipeline/interface_test.go b/controllers/jenkins/pipeline/interface_test.go new file mode 100644 index 00000000..b2530554 --- /dev/null +++ b/controllers/jenkins/pipeline/interface_test.go @@ -0,0 +1,50 @@ +/* +Copyright 2022 The KubeSphere Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pipeline + +import ( + "github.com/stretchr/testify/assert" + "kubesphere.io/devops/controllers/core" + "testing" +) + +func TestInterfaceImplement(t *testing.T) { + type interInstance struct { + NamedReconciler core.NamedReconciler + GroupReconciler core.GroupReconciler + } + + tests := []struct { + name string + instance interInstance + }{{ + name: "JenkinsfileReconciler", + instance: interInstance{ + NamedReconciler: &JenkinsfileReconciler{}, + GroupReconciler: &JenkinsfileReconciler{}, + }, + }} + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + assert.NotNil(t, tt.instance.NamedReconciler) + assert.NotEmpty(t, tt.instance.NamedReconciler.GetName()) + assert.NotNil(t, tt.instance.GroupReconciler) + assert.NotEmpty(t, tt.instance.GroupReconciler.GetGroupName()) + }) + } +} diff --git a/controllers/jenkins/pipeline/json_converter.go b/controllers/jenkins/pipeline/json_converter.go new file mode 100644 index 00000000..a74442dc --- /dev/null +++ b/controllers/jenkins/pipeline/json_converter.go @@ -0,0 +1,158 @@ +/* +Copyright 2022 The KubeSphere Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pipeline + +import ( + "context" + "fmt" + "github.com/go-logr/logr" + "github.com/jenkins-zh/jenkins-client/pkg/core" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/client-go/tools/record" + v1alpha3 "kubesphere.io/devops/pkg/api/devops/v1alpha3" + "kubesphere.io/devops/pkg/jwt/token" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "time" +) + +// tokenExpireIn indicates that the temporary token issued by controller will be expired in some time. +const tokenExpireIn time.Duration = 5 * time.Minute + +//+kubebuilder:rbac:groups=devops.kubesphere.io,resources=pipelines,verbs=get;list;update;patch;watch +//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch + +// JenkinsfileReconciler will convert between JSON and Jenkinsfile (as groovy) formats +type JenkinsfileReconciler struct { + log logr.Logger + recorder record.EventRecorder + + client.Client + JenkinsCore core.JenkinsCore + TokenIssuer token.Issuer +} + +// Reconcile is the main entrypoint of this controller +func (r *JenkinsfileReconciler) Reconcile(req ctrl.Request) (result ctrl.Result, err error) { + ctx := context.Background() + + pip := &v1alpha3.Pipeline{} + if err = r.Get(ctx, req.NamespacedName, pip); err != nil { + err = client.IgnoreNotFound(err) + return + } + + if pip.Spec.Type != v1alpha3.NoScmPipelineType || pip.Spec.Pipeline == nil { + return + } + + // set up the Jenkins client + var c *core.JenkinsCore + if c, err = r.getOrCreateJenkinsCore(map[string]string{ + v1alpha3.PipelineRunCreatorAnnoKey: "admin", + }); err != nil { + err = fmt.Errorf("failed to create Jenkins client, error: %v", err) + return + } + c.RoundTripper = r.JenkinsCore.RoundTripper + coreClient := core.Client{JenkinsCore: *c} + + editMode := pip.Annotations[v1alpha3.PipelineJenkinsfileEditModeAnnoKey] + switch editMode { + case "": + result, err = r.reconcileJenkinsfileEditMode(pip, coreClient) + case v1alpha3.PipelineJenkinsfileEditModeJSON: + result, err = r.reconcileJSONEditMode(pip, coreClient) + default: + r.log.Info(fmt.Sprintf("invalid edit mode: %s", editMode)) + return + } + return +} + +func (r *JenkinsfileReconciler) reconcileJenkinsfileEditMode(pip *v1alpha3.Pipeline, coreClient core.Client) ( + result ctrl.Result, err error) { + + jenkinsfile := pip.Spec.Pipeline.Jenkinsfile + + var toJSONResult core.GenericResult + if toJSONResult, err = coreClient.ToJSON(jenkinsfile); err != nil || toJSONResult.GetStatus() != "success" { + err = fmt.Errorf("failed to convert Jenkinsfile to JSON format, error: %v", err) + return + } + + if pip.Annotations == nil { + pip.Annotations = map[string]string{} + } + pip.Annotations[v1alpha3.PipelineJenkinsfileValueAnnoKey] = toJSONResult.GetResult() + err = r.Update(context.Background(), pip) + return +} + +func (r *JenkinsfileReconciler) reconcileJSONEditMode(pip *v1alpha3.Pipeline, coreClient core.Client) ( + result ctrl.Result, err error) { + var jsonData string + if jsonData = pip.Annotations[v1alpha3.PipelineJenkinsfileValueAnnoKey]; jsonData != "" { + var toResult core.GenericResult + if toResult, err = coreClient.ToJenkinsfile(jsonData); err != nil || toResult.GetStatus() != "success" { + err = fmt.Errorf("failed to convert JSON format to Jenkinsfile, error: %v", err) + return + } + + pip.Annotations[v1alpha3.PipelineJenkinsfileEditModeAnnoKey] = "" + pip.Spec.Pipeline.Jenkinsfile = toResult.GetResult() + err = r.Update(context.Background(), pip) + } + return +} + +// GetName returns the name of this controller +func (r *JenkinsfileReconciler) GetName() string { + return "JenkinsfileController" +} + +// GetGroupName returns the group name of this controller +func (r *JenkinsfileReconciler) GetGroupName() string { + return ControllerGroupName +} + +func (r *JenkinsfileReconciler) 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 setups the log and recorder +func (r *JenkinsfileReconciler) SetupWithManager(mgr ctrl.Manager) error { + r.log = ctrl.Log.WithName(r.GetName()) + r.recorder = mgr.GetEventRecorderFor(r.GetName()) + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha3.Pipeline{}). + Complete(r) +} diff --git a/controllers/jenkins/pipeline/json_converter_test.go b/controllers/jenkins/pipeline/json_converter_test.go new file mode 100644 index 00000000..85fdbdf8 --- /dev/null +++ b/controllers/jenkins/pipeline/json_converter_test.go @@ -0,0 +1,219 @@ +/* +Copyright 2022 The KubeSphere Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pipeline + +import ( + "context" + "fmt" + "github.com/go-logr/logr" + "github.com/golang/mock/gomock" + "github.com/jenkins-zh/jenkins-client/pkg/core" + "github.com/jenkins-zh/jenkins-client/pkg/mock/mhttp" + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "kubesphere.io/devops/pkg/api/devops/v1alpha3" + "kubesphere.io/devops/pkg/jwt/token" + controllerruntime "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/log" + "testing" +) + +func TestJenkinsfileReconciler_Reconcile(t *testing.T) { + schema, err := v1alpha3.SchemeBuilder.Register().Build() + assert.Nil(t, err) + err = v1.SchemeBuilder.AddToScheme(schema) + assert.Nil(t, err) + + defaultReq := controllerruntime.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "ns", + Name: "name", + }, + } + + pip := &v1alpha3.Pipeline{} + pip.SetNamespace("ns") + pip.SetName("name") + pip.Annotations = map[string]string{} + pip.Spec.Type = v1alpha3.NoScmPipelineType + pip.Spec.Pipeline = &v1alpha3.NoScmPipeline{ + Jenkinsfile: `jenkinsfile`, + } + + jsonEditModePip := pip.DeepCopy() + jsonEditModePip.Annotations[v1alpha3.PipelineJenkinsfileEditModeAnnoKey] = "json" + jsonEditModePip.Annotations[v1alpha3.PipelineJenkinsfileValueAnnoKey] = `json` + + invalidEditMode := pip.DeepCopy() + invalidEditMode.Annotations[v1alpha3.PipelineJenkinsfileEditModeAnnoKey] = "invalid" + + irregularPip := pip.DeepCopy() + irregularPip.Spec.Type = "" + + type fields struct { + Client client.Client + log logr.Logger + recorder record.EventRecorder + JenkinsCore core.JenkinsCore + TokenIssuer token.Issuer + } + type args struct { + req controllerruntime.Request + } + tests := []struct { + name string + fields fields + args args + prepare func(t *testing.T, c *core.JenkinsCore) + verify func(t *testing.T, Client client.Client) + wantResult controllerruntime.Result + wantErr assert.ErrorAssertionFunc + }{{ + name: "not found", + fields: fields{ + Client: fake.NewFakeClientWithScheme(schema), + }, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + assert.Nil(t, err) + return true + }, + wantResult: controllerruntime.Result{}, + }, { + name: "invalid edit mode", + fields: fields{ + Client: fake.NewFakeClientWithScheme(schema, invalidEditMode), + JenkinsCore: core.JenkinsCore{}, + log: log.NullLogger{}, + TokenIssuer: &token.FakeIssuer{}, + }, + args: args{ + req: defaultReq, + }, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + assert.Nil(t, err) + return true + }, + }, { + name: "irregular pipeline, and jenkinsfile edit mode", + fields: fields{ + Client: fake.NewFakeClientWithScheme(schema, irregularPip), + JenkinsCore: core.JenkinsCore{}, + log: log.NullLogger{}, + }, + args: args{ + req: defaultReq, + }, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + assert.Nil(t, err) + return true + }, + }, { + name: "a regular pipeline with jenkinsfile edit mode", + fields: fields{ + Client: fake.NewFakeClientWithScheme(schema, pip), + JenkinsCore: core.JenkinsCore{ + URL: "http://localhost", + }, + log: log.NullLogger{}, + TokenIssuer: &token.FakeIssuer{}, + }, + args: args{ + req: defaultReq, + }, + prepare: func(t *testing.T, c *core.JenkinsCore) { + ctrl := gomock.NewController(t) + roundTripper := mhttp.NewMockRoundTripper(ctrl) + c.RoundTripper = roundTripper + + core.PrepareForToJSON(roundTripper, "http://localhost", "", "") + }, + verify: func(t *testing.T, Client client.Client) { + pip := &v1alpha3.Pipeline{} + err := Client.Get(context.Background(), types.NamespacedName{ + Namespace: "ns", + Name: "name", + }, pip) + assert.Nil(t, err) + assert.Equal(t, `{"a":"b"}`, pip.Annotations[v1alpha3.PipelineJenkinsfileValueAnnoKey]) + }, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + assert.Nil(t, err) + return true + }, + }, { + name: "a regular pipeline with JSON edit mode", + fields: fields{ + Client: fake.NewFakeClientWithScheme(schema, jsonEditModePip), + JenkinsCore: core.JenkinsCore{ + URL: "http://localhost", + }, + log: log.NullLogger{}, + TokenIssuer: &token.FakeIssuer{}, + }, + args: args{ + req: defaultReq, + }, + prepare: func(t *testing.T, c *core.JenkinsCore) { + ctrl := gomock.NewController(t) + roundTripper := mhttp.NewMockRoundTripper(ctrl) + c.RoundTripper = roundTripper + + core.PrepareForToJenkinsfile(roundTripper, "http://localhost", "", "") + }, + verify: func(t *testing.T, Client client.Client) { + pip := &v1alpha3.Pipeline{} + err := Client.Get(context.Background(), types.NamespacedName{ + Namespace: "ns", + Name: "name", + }, pip) + assert.Nil(t, err) + assert.Equal(t, "json", pip.Annotations[v1alpha3.PipelineJenkinsfileValueAnnoKey]) + assert.Equal(t, "", pip.Annotations[v1alpha3.PipelineJenkinsfileEditModeAnnoKey]) + assert.Equal(t, "jenkinsfile", pip.Spec.Pipeline.Jenkinsfile) + }, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + assert.Nil(t, err) + return true + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.prepare != nil { + tt.prepare(t, &tt.fields.JenkinsCore) + } + r := &JenkinsfileReconciler{ + Client: tt.fields.Client, + log: tt.fields.log, + recorder: tt.fields.recorder, + JenkinsCore: tt.fields.JenkinsCore, + TokenIssuer: tt.fields.TokenIssuer, + } + gotResult, err := r.Reconcile(tt.args.req) + if !tt.wantErr(t, err, fmt.Sprintf("Reconcile(%v)", tt.args.req)) { + return + } + assert.Equalf(t, tt.wantResult, gotResult, "Reconcile(%v)", tt.args.req) + if tt.verify != nil { + tt.verify(t, tt.fields.Client) + } + }) + } +} diff --git a/controllers/jenkins/pipeline/metadata_converter_test.go b/controllers/jenkins/pipeline/metadata_converter_test.go index a35b8836..9a108e23 100644 --- a/controllers/jenkins/pipeline/metadata_converter_test.go +++ b/controllers/jenkins/pipeline/metadata_converter_test.go @@ -17,8 +17,10 @@ limitations under the License. package pipeline import ( + "kubesphere.io/devops/pkg/models/pipeline" "reflect" "testing" + "time" "github.com/jenkins-zh/jenkins-client/pkg/job" ) @@ -152,3 +154,188 @@ func Test_convertParameterDefinitions(t *testing.T) { }) } } + +func Test_convertCauses(t *testing.T) { + type args struct { + jobCauses []job.Cause + } + tests := []struct { + name string + args args + want []pipeline.Cause + }{{ + name: "normal", + args: args{ + jobCauses: []job.Cause{{ + "shortDescription": "shortDescription", + }}, + }, + want: []pipeline.Cause{{ + ShortDescription: "shortDescription", + }}, + }, { + name: "parameter and result is nil", + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := convertCauses(tt.args.jobCauses); !reflect.DeepEqual(got, tt.want) { + t.Errorf("convertCauses() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_convertBranches(t *testing.T) { + type args struct { + jobBranches []job.PipelineBranch + } + tests := []struct { + name string + args args + want []pipeline.Branch + }{{ + name: "parameter and result is nil", + want: []pipeline.Branch{}, + }, { + name: "normal", + args: args{ + jobBranches: []job.PipelineBranch{{ + BluePipelineItem: job.BluePipelineItem{ + Name: "name", + DisplayName: "displayName", + Disabled: true, + }, + BlueRunnableItem: job.BlueRunnableItem{ + WeatherScore: 100, + }, + }}, + }, + want: []pipeline.Branch{{ + Name: "name", + RawName: "displayName", + WeatherScore: 100, + Disabled: true, + }}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := convertBranches(tt.args.jobBranches); !reflect.DeepEqual(got, tt.want) { + t.Errorf("convertBranches() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_convertLatestRun(t *testing.T) { + now := job.Time{ + Time: time.Now(), + } + var durationInMillis int64 + + type args struct { + jobLatestRun *job.PipelineRunSummary + } + tests := []struct { + name string + args args + want *pipeline.LatestRun + }{{ + name: "parameter and result is nil", + }, { + name: "normal", + args: args{ + jobLatestRun: &job.PipelineRunSummary{ + BlueItemRun: job.BlueItemRun{ + DurationInMillis: &durationInMillis, + EndTime: now, + StartTime: now, + ID: "id", + Name: "name", + Result: "result", + State: "state", + }, + }, + }, + want: &pipeline.LatestRun{ + EndTime: now, + DurationInMillis: &durationInMillis, + StartTime: now, + ID: "id", + Name: "name", + Result: "result", + State: "state", + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := convertLatestRun(tt.args.jobLatestRun); !reflect.DeepEqual(got, tt.want) { + t.Errorf("convertLatestRun() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_convertPipeline(t *testing.T) { + var durationInMillis int64 + + type args struct { + jobPipeline *job.Pipeline + } + tests := []struct { + name string + args args + want *pipeline.Metadata + }{{ + name: "normal", + args: args{ + jobPipeline: &job.Pipeline{ + BlueMultiBranchPipeline: job.BlueMultiBranchPipeline{ + BluePipelineItem: job.BluePipelineItem{ + Name: "name", + Disabled: true, + }, + BlueRunnableItem: job.BlueRunnableItem{ + WeatherScore: 100, + EstimatedDurationInMillis: durationInMillis, + }, + BlueContainerItem: job.BlueContainerItem{ + NumberOfPipelines: 100, + NumberOfFolders: 100, + }, + BlueMultiBranchItem: job.BlueMultiBranchItem{ + BranchNames: []string{"master"}, + NumberOfFailingBranches: 100, + NumberOfSuccessfulBranches: 100, + NumberOfSuccessfulPullRequests: 100, + TotalNumberOfBranches: 100, + TotalNumberOfPullRequests: 100, + }, + ScriptPath: "Jenkinsfile", + }, + }, + }, + want: &pipeline.Metadata{ + WeatherScore: 100, + EstimatedDurationInMillis: durationInMillis, + Name: "name", + Disabled: true, + NumberOfPipelines: 100, + NumberOfFolders: 100, + TotalNumberOfBranches: 100, + NumberOfSuccessfulBranches: 100, + TotalNumberOfPullRequests: 100, + NumberOfFailingBranches: 100, + NumberOfSuccessfulPullRequests: 100, + BranchNames: []string{"master"}, + ScriptPath: "Jenkinsfile", + Parameters: []job.ParameterDefinition{}, + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := convertPipeline(tt.args.jobPipeline); !reflect.DeepEqual(got, tt.want) { + t.Errorf("convertPipeline() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/go.mod b/go.mod index d63dbf49..660bab34 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/h2non/gock v1.0.9 github.com/jenkins-x/go-scm v1.11.4 - github.com/jenkins-zh/jenkins-client v0.0.9 + github.com/jenkins-zh/jenkins-client v0.0.10-0.20220706065616-22f8c7675234 github.com/kubesphere/sonargo v0.0.2 github.com/onsi/ginkgo v1.16.4 github.com/onsi/gomega v1.15.0 diff --git a/go.sum b/go.sum index 1194dcbf..e08511f3 100644 --- a/go.sum +++ b/go.sum @@ -330,8 +330,8 @@ github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NH github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jenkins-zh/jenkins-cli v0.0.32/go.mod h1:uE1mH9PNITrg0sugv6HXuM/CSddg0zxXoYu3w57I3JY= -github.com/jenkins-zh/jenkins-client v0.0.9 h1:yqWtjLifYWbVxR+wFdExh/cHSQ54i/Cdco2MYkc/5vI= -github.com/jenkins-zh/jenkins-client v0.0.9/go.mod h1:ICBk7OOoTafVP//f/VfKZ34c0ff8vJwVnOsF9btiMYU= +github.com/jenkins-zh/jenkins-client v0.0.10-0.20220706065616-22f8c7675234 h1:gO2Ca6t/V7l7SD9H6mAy6UkcHVT0Ey3sZ6o0jIKPUHg= +github.com/jenkins-zh/jenkins-client v0.0.10-0.20220706065616-22f8c7675234/go.mod h1:ICBk7OOoTafVP//f/VfKZ34c0ff8vJwVnOsF9btiMYU= github.com/jenkins-zh/jenkins-formulas v0.0.5/go.mod h1:zS8fm8u5L6FcjZM0QznXsLV9T2UtSVK+hT6Sm76iUZ4= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= diff --git a/pkg/api/devops/v1alpha3/pipeline_types.go b/pkg/api/devops/v1alpha3/pipeline_types.go index e8c8df2b..74019af8 100644 --- a/pkg/api/devops/v1alpha3/pipeline_types.go +++ b/pkg/api/devops/v1alpha3/pipeline_types.go @@ -41,13 +41,18 @@ const ( PipelineJenkinsBranchesAnnoKey = PipelinePrefix + "jenkins-branches" // PipelineRequestToSyncRunsAnnoKey is the annotation key of requesting to synchronize PipelineRun after a dedicated time. PipelineRequestToSyncRunsAnnoKey = PipelinePrefix + "request-to-sync-pipelineruns" + // PipelineJenkinsfileValueAnnoKey is the annotation key of the Jenkinsfile content + PipelineJenkinsfileValueAnnoKey = PipelinePrefix + "jenkinsfile" + // PipelineJenkinsfileEditModeAnnoKey is the annotation key of the Jenkinsfile edit mode + PipelineJenkinsfileEditModeAnnoKey = PipelinePrefix + "jenkinsfile.edit.mode" + + // PipelineJenkinsfileEditModeJSON indicates the Jenkinsfile editing mode is JSON + PipelineJenkinsfileEditModeJSON = "json" ) // PipelineSpec defines the desired state of Pipeline type PipelineSpec struct { - // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster - // Important: Run "make" to regenerate code after modifying this file - Type string `json:"type" description:"type of devops pipeline, in scm or no scm"` + Type PipelineType `json:"type" description:"type of devops pipeline, in scm or no scm"` Pipeline *NoScmPipeline `json:"pipeline,omitempty" description:"no scm pipeline structs"` MultiBranchPipeline *MultiBranchPipeline `json:"multi_branch_pipeline,omitempty" description:"in scm pipeline structs"` } @@ -91,9 +96,12 @@ func (p *Pipeline) IsMultiBranch() bool { return p.Spec.Type == MultiBranchPipelineType } +// PipelineType is an alias of string that represents the type of Pipelines +type PipelineType string + const ( - NoScmPipelineType = "pipeline" - MultiBranchPipelineType = "multi-branch-pipeline" + NoScmPipelineType PipelineType = "pipeline" + MultiBranchPipelineType PipelineType = "multi-branch-pipeline" ) const ( diff --git a/pkg/kapis/devops/v1alpha3/handler.go b/pkg/kapis/devops/v1alpha3/handler.go index db1235ad..91dea78d 100644 --- a/pkg/kapis/devops/v1alpha3/handler.go +++ b/pkg/kapis/devops/v1alpha3/handler.go @@ -204,6 +204,30 @@ func (h *devopsHandler) UpdatePipeline(request *restful.Request, response *restf } } +// GenericPayload represents a generic HTTP request payload data structure +type GenericPayload struct { + Data string `json:"data"` +} + +func (h *devopsHandler) UpdateJenkinsfile(request *restful.Request, response *restful.Response) { + projectName := request.PathParameter("devops") + pipelineName := request.PathParameter("pipeline") + mode := request.QueryParameter("mode") + + var err error + payload := &GenericPayload{} + if err = request.ReadEntity(payload); err != nil { + kapis.HandleBadRequest(response, request, err) + return + } + + var client devops.DevopsOperator + if client, err = h.getDevOps(request); err == nil { + err = client.UpdateJenkinsfile(projectName, pipelineName, mode, payload.Data) + } + errorHandle(request, response, nil, err) +} + func (h *devopsHandler) DeletePipeline(request *restful.Request, response *restful.Response) { devops := request.PathParameter("devops") pipeline := request.PathParameter("pipeline") diff --git a/pkg/kapis/devops/v1alpha3/register.go b/pkg/kapis/devops/v1alpha3/register.go index 3c564afa..1406179a 100644 --- a/pkg/kapis/devops/v1alpha3/register.go +++ b/pkg/kapis/devops/v1alpha3/register.go @@ -171,6 +171,16 @@ func registerRoutersForPipelines(handler *devopsHandler, ws *restful.WebService) Returns(http.StatusOK, api.StatusOK, v1alpha3.Pipeline{}). Metadata(restfulspec.KeyOpenAPITags, []string{constants.DevOpsProjectTag})) + ws.Route(ws.PUT("/devops/{devops}/pipelines/{pipeline}/jenkinsfile"). + To(handler.UpdateJenkinsfile). + Param(ws.PathParameter("devops", "project name")). + Param(ws.PathParameter("pipeline", "pipeline name")). + Param(ws.QueryParameter("mode", "the mode(json or raw) that you expect to update the Jenkinsfile")). + Reads(&GenericPayload{}, "The Jenkinsfile content should be in the 'data' field"). + Doc("Update the Jenkinsfile of a Pipeline"). + Returns(http.StatusOK, api.StatusOK, v1alpha3.Pipeline{}). + Metadata(restfulspec.KeyOpenAPITags, []string{constants.DevOpsProjectTag})) + ws.Route(ws.DELETE("/devops/{devops}/pipelines/{pipeline}"). To(handler.DeletePipeline). Param(ws.PathParameter("devops", "project name")). diff --git a/pkg/kapis/devops/v1alpha3/register_test.go b/pkg/kapis/devops/v1alpha3/register_test.go index 14393028..56590fde 100644 --- a/pkg/kapis/devops/v1alpha3/register_test.go +++ b/pkg/kapis/devops/v1alpha3/register_test.go @@ -159,6 +159,12 @@ func TestAPIsExist(t *testing.T) { method: http.MethodDelete, uri: "/workspaces/fake/devops/fake", }, + }, { + name: "update jenkinsfile", + args: args{ + method: http.MethodPut, + uri: "/devops/fake/pipelines/fake/jenkinsfile", + }, }} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/models/devops/devops.go b/pkg/models/devops/devops.go index 0d271d95..cffd89f0 100644 --- a/pkg/models/devops/devops.go +++ b/pkg/models/devops/devops.go @@ -65,6 +65,7 @@ type DevopsOperator interface { DeletePipelineObj(projectName string, pipelineName string) error UpdatePipelineObj(projectName string, pipeline *v1alpha3.Pipeline) (*v1alpha3.Pipeline, error) ListPipelineObj(projectName string, query *query.Query) (api.ListResult, error) + UpdateJenkinsfile(projectName, pipelineName, mode, jenkinsfile string) error CreateCredentialObj(projectName string, s *v1.Secret) (*v1.Secret, error) GetCredentialObj(projectName string, secretName string) (*v1.Secret, error) @@ -291,6 +292,11 @@ func (d devopsOperator) UpdatePipelineObj(projectName string, pipeline *v1alpha3 // trying to avoid the error of `Operation cannot be fulfilled on` by getting the latest resourceVersion if latestPipe, err := d.ksclient.DevopsV1alpha3().Pipelines(ns).Get(d.context, name, metav1.GetOptions{}); err == nil { pipeline.ResourceVersion = latestPipe.ResourceVersion + + // avoid update the Jenkinsfile in this API, see also UpdateJenkinsfile + if pipeline.Spec.Pipeline != nil && latestPipe.Spec.Pipeline != nil { + pipeline.Spec.Pipeline.Jenkinsfile = latestPipe.Spec.Pipeline.Jenkinsfile + } } else { return nil, fmt.Errorf("cannot found pipeline %s/%s, error: %v", ns, name, err) } @@ -298,6 +304,30 @@ func (d devopsOperator) UpdatePipelineObj(projectName string, pipeline *v1alpha3 return d.ksclient.DevopsV1alpha3().Pipelines(ns).Update(d.context, pipeline, metav1.UpdateOptions{}) } +// UpdateJenkinsfile updates the Jenkinsfile value with specific edit mode +func (d devopsOperator) UpdateJenkinsfile(projectName, pipelineName, mode, jenkinsfile string) (err error) { + var pipeline *devopsv1alpha3.Pipeline + if pipeline, err = d.ksclient.DevopsV1alpha3().Pipelines(projectName).Get(d.context, pipelineName, metav1.GetOptions{}); err != nil { + return + } + + if pipeline.Annotations == nil { + pipeline.Annotations = map[string]string{} + } + switch mode { + case devopsv1alpha3.PipelineJenkinsfileEditModeJSON: + pipeline.Annotations[devopsv1alpha3.PipelineJenkinsfileEditModeAnnoKey] = devopsv1alpha3.PipelineJenkinsfileEditModeJSON + pipeline.Annotations[devopsv1alpha3.PipelineJenkinsfileValueAnnoKey] = jenkinsfile + default: + pipeline.Annotations[devopsv1alpha3.PipelineJenkinsfileEditModeAnnoKey] = "" + if pipeline.Spec.Pipeline != nil { + pipeline.Spec.Pipeline.Jenkinsfile = jenkinsfile + } + } + _, err = d.ksclient.DevopsV1alpha3().Pipelines(projectName).Update(d.context, pipeline, metav1.UpdateOptions{}) + return +} + func (d devopsOperator) ListPipelineObj(projectName string, query *query.Query) (api.ListResult, error) { project, err := d.ksclient.DevopsV1alpha3().DevOpsProjects().Get(d.context, projectName, metav1.GetOptions{}) if err != nil { diff --git a/pkg/models/devops/devops_test.go b/pkg/models/devops/devops_test.go index 7172816b..1f4eb60c 100644 --- a/pkg/models/devops/devops_test.go +++ b/pkg/models/devops/devops_test.go @@ -18,6 +18,8 @@ package devops import ( "context" + "fmt" + "k8s.io/client-go/kubernetes" "net/http" "testing" @@ -325,3 +327,199 @@ func Test_devopsOperator_GetDevOpsProject(t *testing.T) { }) } } + +func Test_devopsOperator_UpdateJenkinsfile(t *testing.T) { + pipeline := &v1alpha3.Pipeline{} + pipeline.SetNamespace("ns") + pipeline.SetName("fake") + pipeline.Spec.Pipeline = &v1alpha3.NoScmPipeline{} + + type fields struct { + devopsClient devops.Interface + k8sclient kubernetes.Interface + ksclient versioned.Interface + context context.Context + } + type args struct { + projectName string + pipelineName string + mode string + jenkinsfile string + } + tests := []struct { + name string + fields fields + args args + wantErr assert.ErrorAssertionFunc + verify func(t *testing.T, ksclient versioned.Interface) + }{{ + name: "not found pipeline", + fields: fields{ + ksclient: fakeclientset.NewSimpleClientset(), + }, + args: args{ + projectName: "ns", + pipelineName: "fake", + }, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + assert.NotNil(t, err) + return true + }, + }, { + name: "json mode", + fields: fields{ + ksclient: fakeclientset.NewSimpleClientset(pipeline.DeepCopy()), + }, + args: args{ + projectName: "ns", + pipelineName: "fake", + mode: "json", + jenkinsfile: "json-format-jenkinsfile", + }, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + assert.Nil(t, err) + return true + }, + verify: func(t *testing.T, ksclient versioned.Interface) { + pip, err := ksclient.DevopsV1alpha3().Pipelines("ns").Get(context.Background(), "fake", metav1.GetOptions{}) + assert.Nil(t, err) + assert.Equal(t, "json-format-jenkinsfile", pip.Annotations[v1alpha3.PipelineJenkinsfileValueAnnoKey]) + assert.Equal(t, "json", pip.Annotations[v1alpha3.PipelineJenkinsfileEditModeAnnoKey]) + }, + }, { + name: "mode value is empty", + fields: fields{ + ksclient: fakeclientset.NewSimpleClientset(pipeline.DeepCopy()), + }, + args: args{ + projectName: "ns", + pipelineName: "fake", + mode: "", + jenkinsfile: "jenkinsfile", + }, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + assert.Nil(t, err) + return true + }, + verify: func(t *testing.T, ksclient versioned.Interface) { + pip, err := ksclient.DevopsV1alpha3().Pipelines("ns").Get(context.Background(), "fake", metav1.GetOptions{}) + assert.Nil(t, err) + assert.Equal(t, "jenkinsfile", pip.Spec.Pipeline.Jenkinsfile) + assert.Equal(t, "", pip.Annotations[v1alpha3.PipelineJenkinsfileEditModeAnnoKey]) + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := devopsOperator{ + devopsClient: tt.fields.devopsClient, + k8sclient: tt.fields.k8sclient, + ksclient: tt.fields.ksclient, + context: tt.fields.context, + } + tt.wantErr(t, d.UpdateJenkinsfile(tt.args.projectName, tt.args.pipelineName, tt.args.mode, tt.args.jenkinsfile), fmt.Sprintf("UpdateJenkinsfile(%v, %v, %v, %v)", tt.args.projectName, tt.args.pipelineName, tt.args.mode, tt.args.jenkinsfile)) + if tt.verify != nil { + tt.verify(t, tt.fields.ksclient) + } + }) + } +} + +func Test_devopsOperator_UpdatePipelineObj(t *testing.T) { + pip := &v1alpha3.Pipeline{} + pip.SetName("fake") + pip.SetNamespace("ns") + + pipWithJenkinsfile := pip.DeepCopy() + pipWithJenkinsfile.Spec.Pipeline = &v1alpha3.NoScmPipeline{} + + project := &v1alpha3.DevOpsProject{} + project.SetName("ns") + project.Status.AdminNamespace = "ns" + + type fields struct { + devopsClient devops.Interface + k8sclient kubernetes.Interface + ksclient versioned.Interface + context context.Context + } + type args struct { + projectName string + pipeline *v1alpha3.Pipeline + } + tests := []struct { + name string + fields fields + args args + want *v1alpha3.Pipeline + wantErr assert.ErrorAssertionFunc + }{{ + name: "not found project", + fields: fields{ + ksclient: fakeclientset.NewSimpleClientset(), + }, + args: args{ + projectName: "ns", + pipeline: pip.DeepCopy(), + }, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + assert.NotNil(t, err) + return true + }, + }, { + name: "not found pipeline", + fields: fields{ + ksclient: fakeclientset.NewSimpleClientset(project.DeepCopy()), + }, + args: args{ + projectName: "ns", + pipeline: pip.DeepCopy(), + }, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + assert.NotNil(t, err) + return true + }, + }, { + name: "without jenkinsfile", + fields: fields{ + ksclient: fakeclientset.NewSimpleClientset(project.DeepCopy(), pip.DeepCopy()), + }, + args: args{ + projectName: "ns", + pipeline: pip.DeepCopy(), + }, + want: pip.DeepCopy(), + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + assert.Nil(t, err) + return true + }, + }, { + name: "normal case, with jenkinsfile", + fields: fields{ + ksclient: fakeclientset.NewSimpleClientset(project.DeepCopy(), pipWithJenkinsfile.DeepCopy()), + }, + args: args{ + projectName: "ns", + pipeline: pipWithJenkinsfile.DeepCopy(), + }, + want: pipWithJenkinsfile.DeepCopy(), + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + assert.Nil(t, err) + return true + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := devopsOperator{ + devopsClient: tt.fields.devopsClient, + k8sclient: tt.fields.k8sclient, + ksclient: tt.fields.ksclient, + context: tt.fields.context, + } + got, err := d.UpdatePipelineObj(tt.args.projectName, tt.args.pipeline) + if !tt.wantErr(t, err, fmt.Sprintf("UpdatePipelineObj(%v, %v)", tt.args.projectName, tt.args.pipeline)) { + return + } + assert.Equalf(t, tt.want, got, "UpdatePipelineObj(%v, %v)", tt.args.projectName, tt.args.pipeline) + }) + } +}