diff --git a/pkg/controller/jenkins/client/jenkins.go b/pkg/controller/jenkins/client/jenkins.go index 0e278cff..5d6f6f35 100644 --- a/pkg/controller/jenkins/client/jenkins.go +++ b/pkg/controller/jenkins/client/jenkins.go @@ -5,13 +5,17 @@ import ( "fmt" "net/http" "os/exec" + "regexp" "strings" "github.com/bndr/gojenkins" "github.com/pkg/errors" ) -var errorNotFound = errors.New("404") +var ( + errorNotFound = errors.New("404") + regex = regexp.MustCompile("()(?P[a-z0-9]*)") +) // Jenkins defines Jenkins API type Jenkins interface { @@ -52,6 +56,7 @@ type Jenkins interface { CreateView(name string, viewType string) (*gojenkins.View, error) Poll() (int, error) ExecuteScript(groovyScript string) (logs string, err error) + GetNodeSecret(name string) (string, error) } type jenkins struct { @@ -136,3 +141,27 @@ func isNotFoundError(err error) bool { } return false } + +func (jenkins *jenkins) GetNodeSecret(name string) (string, error) { + var content string + _, err := jenkins.Requester.GetXML(fmt.Sprintf("/computer/%s/slave-agent.jnlp", name), &content, nil) + + if err != nil { + return "", err + } + + match := regex.FindStringSubmatch(content) + if match == nil { + return "", errors.New("Node secret cannot be parsed") + } + + result := make(map[string]string) + + for i, name := range regex.SubexpNames() { + if i != 0 && name != "" { + result[name] = match[i] + } + } + + return result["secret"], nil +} diff --git a/pkg/controller/jenkins/client/mockgen.go b/pkg/controller/jenkins/client/mockgen.go index 062b7bd0..404584df 100644 --- a/pkg/controller/jenkins/client/mockgen.go +++ b/pkg/controller/jenkins/client/mockgen.go @@ -16,6 +16,19 @@ type MockJenkins struct { recorder *MockJenkinsMockRecorder } +func (m *MockJenkins) GetNodeSecret(name string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNodeSecret", name) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +func (mr *MockJenkinsMockRecorder) GetNodeSecret(name string) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNodeSecret", reflect.TypeOf((*MockJenkins)(nil).GetNodeSecret), name) +} + // MockJenkinsMockRecorder is the mock recorder for MockJenkins type MockJenkinsMockRecorder struct { mock *MockJenkins diff --git a/pkg/controller/jenkins/configuration/base/resources/base_configuration_configmap.go b/pkg/controller/jenkins/configuration/base/resources/base_configuration_configmap.go index 18ec0b2e..258e5756 100644 --- a/pkg/controller/jenkins/configuration/base/resources/base_configuration_configmap.go +++ b/pkg/controller/jenkins/configuration/base/resources/base_configuration_configmap.go @@ -2,7 +2,6 @@ package resources import ( "fmt" - "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/constants" diff --git a/pkg/controller/jenkins/configuration/user/seedjobs/seedjobs.go b/pkg/controller/jenkins/configuration/user/seedjobs/seedjobs.go index 26eccdc8..3c79dbfa 100644 --- a/pkg/controller/jenkins/configuration/user/seedjobs/seedjobs.go +++ b/pkg/controller/jenkins/configuration/user/seedjobs/seedjobs.go @@ -11,27 +11,21 @@ import ( jenkinsclient "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/client" "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/configuration/base/resources" "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/constants" - "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/jobs" - "github.com/jenkinsci/kubernetes-operator/pkg/log" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/groovy" "github.com/go-logr/logr" stackerr "github.com/pkg/errors" + apps "k8s.io/api/apps/v1" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" k8s "sigs.k8s.io/controller-runtime/pkg/client" ) const ( - // ConfigureSeedJobsName this is the fixed seed job name - ConfigureSeedJobsName = constants.OperatorName + "-configure-seed-job" - - idParameterName = "ID" - credentialIDParameterName = "CREDENTIAL_ID" - repositoryURLParameterName = "REPOSITORY_URL" - repositoryBranchParameterName = "REPOSITORY_BRANCH" - targetsParameterName = "TARGETS" - displayNameParameterName = "SEED_JOB_DISPLAY_NAME" - // UsernameSecretKey is username data key in Kubernetes secret used to create Jenkins username/password credential UsernameSecretKey = "username" // PasswordSecretKey is password data key in Kubernetes secret used to create Jenkins username/password credential @@ -42,6 +36,9 @@ const ( // JenkinsCredentialTypeLabelName is label for kubernetes-credentials-provider-plugin which determine Jenkins // credential type JenkinsCredentialTypeLabelName = "jenkins.io/credentials-type" + + // AgentName is the name of seed job agent + AgentName = "jnlp" ) // SeedJobs defines API for configuring and ensuring Jenkins Seed Jobs and Deploy Keys @@ -67,20 +64,35 @@ func (s *SeedJobs) EnsureSeedJobs(jenkins *v1alpha2.Jenkins) (done bool, err err return false, s.restartJenkinsMasterPod(*jenkins) } - if err = s.createJob(); err != nil { - s.logger.V(log.VWarn).Info("Couldn't create jenkins seed job") - return false, err + if len(jenkins.Spec.SeedJobs) > 0 { + err := s.createAgent(s.jenkinsClient, s.k8sClient, jenkins, jenkins.Namespace, AgentName) + if err != nil { + return false, err + } + } else if len(jenkins.Spec.SeedJobs) == 0 { + err := s.k8sClient.Delete(context.TODO(), &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: jenkins.Namespace, + Name: AgentName, + }, + }) + + if err != nil && !apierrors.IsNotFound(err) { + return false, err + } } if err = s.ensureLabelsForSecrets(*jenkins); err != nil { return false, err } - done, err = s.buildJobs(jenkins) + requeue, err := s.createJobs(jenkins) if err != nil { - s.logger.V(log.VWarn).Info("Couldn't build jenkins seed job") return false, err } + if requeue { + return false, nil + } seedJobIDs := s.getAllSeedJobIDs(*jenkins) if done && !reflect.DeepEqual(seedJobIDs, jenkins.Status.CreatedSeedJobs) { @@ -88,19 +100,35 @@ func (s *SeedJobs) EnsureSeedJobs(jenkins *v1alpha2.Jenkins) (done bool, err err return false, stackerr.WithStack(s.k8sClient.Update(context.TODO(), jenkins)) } - return done, nil + return true, nil } // createJob is responsible for creating jenkins job which configures jenkins seed jobs and deploy keys -func (s *SeedJobs) createJob() error { - _, created, err := s.jenkinsClient.CreateOrUpdateJob(seedJobConfigXML, ConfigureSeedJobsName) - if err != nil { - return err +func (s *SeedJobs) createJobs(jenkins *v1alpha2.Jenkins) (requeue bool, err error) { + groovyClient := groovy.New(s.jenkinsClient, s.k8sClient, s.logger, jenkins, "user-groovy", jenkins.Spec.GroovyScripts.Customization) + for _, seedJob := range jenkins.Spec.SeedJobs { + credentialValue, err := s.credentialValue(jenkins.Namespace, seedJob) + if err != nil { + return true, err + } + + groovyScript := seedJobCreatingGroovyScript(seedJob) + + hash := sha256.New() + hash.Write([]byte(groovyScript)) + hash.Write([]byte(credentialValue)) + requeue, err := groovyClient.EnsureSingle(seedJob.ID, fmt.Sprintf("%s.groovy", seedJob.ID), base64.URLEncoding.EncodeToString(hash.Sum(nil)), groovyScript) + + if err != nil { + return true, err + } + + if requeue { + return true, nil + } } - if created { - s.logger.Info(fmt.Sprintf("'%s' job has been created", ConfigureSeedJobsName)) - } - return nil + + return false, nil } // ensureLabelsForSecrets adds labels to Kubernetes secrets where are Jenkins credentials used for seed jobs, @@ -132,45 +160,6 @@ func (s *SeedJobs) ensureLabelsForSecrets(jenkins v1alpha2.Jenkins) error { return nil } -// buildJobs is responsible for running jenkins builds which configures jenkins seed jobs and deploy keys -func (s *SeedJobs) buildJobs(jenkins *v1alpha2.Jenkins) (done bool, err error) { - allDone := true - for _, seedJob := range jenkins.Spec.SeedJobs { - credentialValue, err := s.credentialValue(jenkins.Namespace, seedJob) - if err != nil { - return false, err - } - parameters := map[string]string{ - idParameterName: seedJob.ID, - credentialIDParameterName: seedJob.CredentialID, - repositoryURLParameterName: seedJob.RepositoryURL, - repositoryBranchParameterName: seedJob.RepositoryBranch, - targetsParameterName: seedJob.Targets, - displayNameParameterName: fmt.Sprintf("Seed Job from %s", seedJob.ID), - } - - hash := sha256.New() - hash.Write([]byte(parameters[idParameterName])) - hash.Write([]byte(parameters[credentialIDParameterName])) - hash.Write([]byte(credentialValue)) - hash.Write([]byte(parameters[repositoryURLParameterName])) - hash.Write([]byte(parameters[repositoryBranchParameterName])) - hash.Write([]byte(parameters[targetsParameterName])) - hash.Write([]byte(parameters[displayNameParameterName])) - encodedHash := base64.URLEncoding.EncodeToString(hash.Sum(nil)) - - jobsClient := jobs.New(s.jenkinsClient, s.k8sClient, s.logger) - done, err := jobsClient.EnsureBuildJob(ConfigureSeedJobsName, encodedHash, parameters, jenkins, true) - if err != nil { - return false, err - } - if !done { - allDone = false - } - } - return allDone, nil -} - func (s *SeedJobs) credentialValue(namespace string, seedJob v1alpha2.SeedJob) (string, error) { if seedJob.JenkinsCredentialType == v1alpha2.BasicSSHCredentialType || seedJob.JenkinsCredentialType == v1alpha2.UsernamePasswordCredentialType { secret := &corev1.Secret{} @@ -233,56 +222,117 @@ func (s *SeedJobs) isRecreatePodNeeded(jenkins v1alpha2.Jenkins) bool { return false } -// seedJobConfigXML this is the XML representation of seed job -var seedJobConfigXML = ` - - - Configure Seed Jobs - false - - - - - ` + idParameterName + ` - - - false - - - ` + credentialIDParameterName + ` - - - false - - - ` + repositoryURLParameterName + ` - - - false - - - ` + repositoryBranchParameterName + ` - - master - false - - - ` + displayNameParameterName + ` - - - false - - - ` + targetsParameterName + ` - - cicd/jobs/*.jenkins - false - - - - - - - false - - - false - + ` +} diff --git a/pkg/controller/jenkins/configuration/user/seedjobs/seedjobs_test.go b/pkg/controller/jenkins/configuration/user/seedjobs/seedjobs_test.go index 3dda0eb3..f7f06f78 100644 --- a/pkg/controller/jenkins/configuration/user/seedjobs/seedjobs_test.go +++ b/pkg/controller/jenkins/configuration/user/seedjobs/seedjobs_test.go @@ -2,16 +2,17 @@ package seedjobs import ( "context" - "fmt" + "k8s.io/apimachinery/pkg/api/errors" "testing" "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" - "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/client" + jenkinsclient "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/client" "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/configuration/base/resources" "github.com/bndr/gojenkins" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -21,97 +22,6 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/runtime/log" ) -func TestEnsureSeedJobs(t *testing.T) { - // given - logger := logf.ZapLogger(false) - ctrl := gomock.NewController(t) - ctx := context.TODO() - defer ctrl.Finish() - - jenkinsClient := client.NewMockJenkins(ctrl) - fakeClient := fake.NewFakeClient() - err := v1alpha2.SchemeBuilder.AddToScheme(scheme.Scheme) - assert.NoError(t, err) - - jenkins := jenkinsCustomResource() - err = fakeClient.Create(ctx, jenkins) - assert.NoError(t, err) - buildNumber := int64(1) - - for reconcileAttempt := 1; reconcileAttempt <= 2; reconcileAttempt++ { - logger.Info(fmt.Sprintf("Reconcile attempt #%d", reconcileAttempt)) - - seedJobs := New(jenkinsClient, fakeClient, logger) - - // first run - should create job and schedule build - if reconcileAttempt == 1 { - jenkinsClient. - EXPECT(). - CreateOrUpdateJob(seedJobConfigXML, ConfigureSeedJobsName). - Return(nil, true, nil) - - jenkinsClient. - EXPECT(). - GetJob(ConfigureSeedJobsName). - Return(&gojenkins.Job{ - Raw: &gojenkins.JobResponse{ - NextBuildNumber: buildNumber, - }, - }, nil) - - jenkinsClient. - EXPECT(). - BuildJob(ConfigureSeedJobsName, gomock.Any()). - Return(int64(0), nil) - } - - // second run - should update and finish job - if reconcileAttempt == 2 { - jenkinsClient. - EXPECT(). - CreateOrUpdateJob(seedJobConfigXML, ConfigureSeedJobsName). - Return(nil, false, nil) - - jenkinsClient. - EXPECT(). - GetBuild(ConfigureSeedJobsName, gomock.Any()). - Return(&gojenkins.Build{ - Raw: &gojenkins.BuildResponse{ - Result: string(v1alpha2.BuildSuccessStatus), - }, - }, nil) - } - - done, err := seedJobs.EnsureSeedJobs(jenkins) - assert.NoError(t, err) - - err = fakeClient.Get(ctx, types.NamespacedName{Name: jenkins.Name, Namespace: jenkins.Namespace}, jenkins) - assert.NoError(t, err) - - assert.Equal(t, 1, len(jenkins.Status.Builds), "There is one running job") - build := jenkins.Status.Builds[0] - assert.Equal(t, buildNumber, build.Number) - assert.Equal(t, ConfigureSeedJobsName, build.JobName) - assert.NotNil(t, build.CreateTime) - assert.NotEmpty(t, build.Hash) - assert.NotNil(t, build.LastUpdateTime) - assert.Equal(t, 0, build.Retires) - - // first run - should create job and schedule build - if reconcileAttempt == 1 { - assert.False(t, done) - assert.Equal(t, string(v1alpha2.BuildRunningStatus), string(build.Status)) - } - - // second run - should update and finish job - if reconcileAttempt == 2 { - assert.False(t, done) - assert.Equal(t, string(v1alpha2.BuildSuccessStatus), string(build.Status)) - } - - } -} - func jenkinsCustomResource() *v1alpha2.Jenkins { return &v1alpha2.Jenkins{ ObjectMeta: metav1.ObjectMeta{ @@ -152,6 +62,130 @@ func jenkinsCustomResource() *v1alpha2.Jenkins { } } +func TestEnsureSeedJobs(t *testing.T) { + t.Run("happy", func(t *testing.T) { + // given + logger := logf.ZapLogger(false) + ctrl := gomock.NewController(t) + ctx := context.TODO() + defer ctrl.Finish() + + jenkinsClient := jenkinsclient.NewMockJenkins(ctrl) + fakeClient := fake.NewFakeClient() + err := v1alpha2.SchemeBuilder.AddToScheme(scheme.Scheme) + assert.NoError(t, err) + + jenkins := jenkinsCustomResource() + err = fakeClient.Create(ctx, jenkins) + assert.NoError(t, err) + + agentName := "jnlp" + agentSecret := "test-secret" + testNode := &gojenkins.Node{ + Raw: &gojenkins.NodeResponse{ + DisplayName: agentName, + }, + } + + jenkinsClient.EXPECT().GetNode(agentName).Return(nil, nil).AnyTimes() + jenkinsClient.EXPECT().CreateNode(agentName, 1, "The jenkins-operator generated agent", "/home/jenkins", agentName).Return(testNode, nil).AnyTimes() + jenkinsClient.EXPECT().GetNodeSecret(agentName).Return(agentSecret, nil).AnyTimes() + jenkinsClient.EXPECT().ExecuteScript(seedJobCreatingGroovyScript(jenkins.Spec.SeedJobs[0])).AnyTimes() + + seedJobClient := New(jenkinsClient, fakeClient, logger) + + // when + _, err = seedJobClient.EnsureSeedJobs(jenkins) + + // then + assert.NoError(t, err) + + var agentDeployment appsv1.Deployment + err = fakeClient.Get(ctx, types.NamespacedName{Namespace: jenkins.Namespace, Name: agentName}, &agentDeployment) + assert.NoError(t, err) + }) + + t.Run("delete agent deployment when no seed jobs", func(t *testing.T) { + // given + ctrl := gomock.NewController(t) + ctx := context.TODO() + defer ctrl.Finish() + + agentName := "test-agent" + agentSecret := "test-secret" + jenkins := jenkinsCustomResource() + jenkins.Spec.SeedJobs = []v1alpha2.SeedJob{} + + jenkinsClient := jenkinsclient.NewMockJenkins(ctrl) + fakeClient := fake.NewFakeClient() + err := v1alpha2.SchemeBuilder.AddToScheme(scheme.Scheme) + assert.NoError(t, err) + + jenkinsClient.EXPECT().GetNode(agentName).AnyTimes() + jenkinsClient.EXPECT().CreateNode(agentName, 1, "The jenkins-operator generated agent", "/home/jenkins", agentName).AnyTimes() + jenkinsClient.EXPECT().GetNodeSecret(agentName).Return(agentSecret, nil).AnyTimes() + + seedJobsClient := New(jenkinsClient, fakeClient, nil) + + err = fakeClient.Create(ctx, &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: agentName, + Namespace: jenkins.Namespace, + }, + }) + assert.NoError(t, err) + + // when + _, err = seedJobsClient.EnsureSeedJobs(jenkins) + + // then + assert.NoError(t, err) + + var deployment appsv1.Deployment + err = fakeClient.Get(ctx, types.NamespacedName{Name: agentName, Namespace: jenkins.Namespace}, &deployment) + + assert.False(t, errors.IsNotFound(err), "Agent deployment hasn't been deleted") + }) +} + +func TestCreateAgent(t *testing.T) { + t.Run("don't fail when deployment is already created", func(t *testing.T) { + // given + ctrl := gomock.NewController(t) + ctx := context.TODO() + defer ctrl.Finish() + + agentName := "test-agent" + agentSecret := "test-secret" + jenkins := jenkinsCustomResource() + + jenkinsClient := jenkinsclient.NewMockJenkins(ctrl) + fakeClient := fake.NewFakeClient() + err := v1alpha2.SchemeBuilder.AddToScheme(scheme.Scheme) + assert.NoError(t, err) + + jenkinsClient.EXPECT().GetNode(agentName).AnyTimes() + jenkinsClient.EXPECT().CreateNode(agentName, 1, "The jenkins-operator generated agent", "/home/jenkins", agentName).AnyTimes() + jenkinsClient.EXPECT().GetNodeSecret(agentName).Return(agentSecret, nil).AnyTimes() + + seedJobsClient := New(jenkinsClient, fakeClient, nil) + + err = fakeClient.Create(ctx, &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: agentName, + Namespace: jenkins.Namespace, + }, + }) + assert.NoError(t, err) + + // when + err = seedJobsClient.createAgent(jenkinsClient, fakeClient, jenkinsCustomResource(), jenkins.Namespace, agentName) + + // then + assert.NoError(t, err) + }) +} + func TestSeedJobs_isRecreatePodNeeded(t *testing.T) { seedJobsClient := New(nil, nil, nil) t.Run("empty", func(t *testing.T) { diff --git a/pkg/controller/jenkins/constants/constants.go b/pkg/controller/jenkins/constants/constants.go index dc83a1d2..61efec9d 100644 --- a/pkg/controller/jenkins/constants/constants.go +++ b/pkg/controller/jenkins/constants/constants.go @@ -4,7 +4,7 @@ const ( // OperatorName is a operator name OperatorName = "jenkins-operator" // DefaultAmountOfExecutors is the default amount of Jenkins executors - DefaultAmountOfExecutors = 3 + DefaultAmountOfExecutors = 0 // SeedJobSuffix is a suffix added for all seed jobs SeedJobSuffix = "job-dsl-seed" // DefaultJenkinsMasterImage is the default Jenkins master docker image