diff --git a/docs/getting-started.md b/docs/getting-started.md index 4e63cd7b..6907e1b3 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -5,12 +5,13 @@ This document describes a getting started guide for **jenkins-operator** and an 1. [First Steps](#first-steps) 2. [Deploy Jenkins](#deploy-jenkins) 3. [Configure Seed Jobs and Pipelines](#configure-seed-jobs-and-pipelines) -4. [Install Plugins](#install-plugins) -5. [Configure Backup & Restore](#configure-backup-and-restore) -6. [AKS](#aks) -7. [Jenkins login credentials](#jenkins-login-credentials) -8. [Override default Jenkins container command](#override-default-Jenkins-container-command) -9. [Debugging](#debugging) +4. [Pulling Docker images from private repositories](#pulling-docker-images-from-private-repositories) +5. [Install Plugins](#install-plugins) +6. [Configure Backup & Restore](#configure-backup-and-restore) +7. [AKS](#aks) +8. [Jenkins login credentials](#jenkins-login-credentials) +9. [Override default Jenkins container command](#override-default-Jenkins-container-command) +10. [Debugging](#debugging) ## First Steps @@ -293,6 +294,79 @@ data: password: password_or_token ``` +## Pulling Docker images from private repositories +To pull Docker Image from private repository you can use `imagePullSecrets`. + +Please follow the instructions on [creating a secret with a docker config](https://kubernetes.io/docs/concepts/containers/images/?origin_team=T42NTAGHM#creating-a-secret-with-a-docker-config). + +### Docker Hub Configuration +To use Docker Hub additional steps are required. + +Edit the previously created secret: +```bash +kubectl -n edit secret +``` + +The `.dockerconfigjson` key's value needs to be replaced with a modified version. + +After modifications it needs to be encoded as Base64 value before setting the `.dockerconfigjson` key:q. + +Example config file to modify and use: +``` +{ + "auths":{ + "https://index.docker.io/v1/":{ + "username":"user", + "password":"password", + "email":"yourdockeremail@gmail.com", + "auth":"base64 of string user:password" + }, + "auth.docker.io":{ + "username":"user", + "password":"password", + "email":"yourdockeremail@gmail.com", + "auth":"base64 of string user:password" + }, + "registry.docker.io":{ + "username":"user", + "password":"password", + "email":"yourdockeremail@gmail.com", + "auth":"base64 of string user:password" + }, + "docker.io":{ + "username":"user", + "password":"password", + "email":"yourdockeremail@gmail.com", + "auth":"base64 of string user:password" + }, + "https://registry-1.docker.io/v2/": { + "username":"user", + "password":"password", + "email":"yourdockeremail@gmail.com", + "auth":"base64 of string user:password" + }, + "registry-1.docker.io/v2/": { + "username":"user", + "password":"password", + "email":"yourdockeremail@gmail.com", + "auth":"base64 of string user:password" + }, + "registry-1.docker.io": { + "username":"user", + "password":"password", + "email":"yourdockeremail@gmail.com", + "auth":"base64 of string user:password" + }, + "https://registry-1.docker.io": { + "username":"user", + "password":"password", + "email":"yourdockeremail@gmail.com", + "auth":"base64 of string user:password" + } + } +} +``` + ## Jenkins Customisation Jenkins can be customized using groovy scripts or configuration as code plugin. All custom configuration is stored in diff --git a/docs/migration-guide-v1alphav1-to-v1alpha2.md b/docs/migration-guide-v1alphav1-to-v1alpha2.md index 6b70f3df..7c7ac93a 100644 --- a/docs/migration-guide-v1alphav1-to-v1alpha2.md +++ b/docs/migration-guide-v1alphav1-to-v1alpha2.md @@ -308,7 +308,7 @@ To use default CRD file: kubectl -n apply -f https://github.com/jenkinsci/kubernetes-operator/blob/master/deploy/crds/jenkins_v1alpha2_jenkins_crd.yaml ``` -## Update RBAC to new verison +## Update RBAC to new version New operator version requires updated RBAC permissions: diff --git a/pkg/apis/jenkins/v1alpha2/jenkins_types.go b/pkg/apis/jenkins/v1alpha2/jenkins_types.go index abc35481..a961b7a9 100644 --- a/pkg/apis/jenkins/v1alpha2/jenkins_types.go +++ b/pkg/apis/jenkins/v1alpha2/jenkins_types.go @@ -210,6 +210,13 @@ type JenkinsMaster struct { // memory: 600Mi Containers []Container `json:"containers,omitempty"` + // ImagePullSecrets is an optional list of references to secrets in the same namespace to use for pulling any of the images used by this PodSpec. + // If specified, these secrets will be passed to individual puller implementations for them to use. For example, + // in the case of docker, only DockerConfig type secrets are honored. + // More info: https://kubernetes.io/docs/concepts/containers/images#specifying-imagepullsecrets-on-a-pod + // +optional + ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` + // List of volumes that can be mounted by containers belonging to the pod. // More info: https://kubernetes.io/docs/concepts/storage/volumes // +optional diff --git a/pkg/apis/jenkins/v1alpha2/zz_generated.deepcopy.go b/pkg/apis/jenkins/v1alpha2/zz_generated.deepcopy.go index ab88bd8c..33b5ff35 100644 --- a/pkg/apis/jenkins/v1alpha2/zz_generated.deepcopy.go +++ b/pkg/apis/jenkins/v1alpha2/zz_generated.deepcopy.go @@ -322,6 +322,11 @@ func (in *JenkinsMaster) DeepCopyInto(out *JenkinsMaster) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.ImagePullSecrets != nil { + in, out := &in.ImagePullSecrets, &out.ImagePullSecrets + *out = make([]v1.LocalObjectReference, len(*in)) + copy(*out, *in) + } if in.Volumes != nil { in, out := &in.Volumes, &out.Volumes *out = make([]v1.Volume, len(*in)) diff --git a/pkg/controller/jenkins/configuration/base/reconcile.go b/pkg/controller/jenkins/configuration/base/reconcile.go index 7cb591a4..ed8ac2f1 100644 --- a/pkg/controller/jenkins/configuration/base/reconcile.go +++ b/pkg/controller/jenkins/configuration/base/reconcile.go @@ -527,6 +527,12 @@ func (r *ReconcileJenkinsBaseConfiguration) isRecreatePodNeeded(currentJenkinsMa return true } + if !reflect.DeepEqual(r.jenkins.Spec.Master.ImagePullSecrets, currentJenkinsMasterPod.Spec.ImagePullSecrets) { + r.logger.Info(fmt.Sprintf("Jenkins Pod ImagePullSecrets has changed, actual '%+v' required '%+v', recreating pod", + currentJenkinsMasterPod.Spec.ImagePullSecrets, r.jenkins.Spec.Master.ImagePullSecrets)) + return true + } + if !reflect.DeepEqual(r.jenkins.Spec.Master.NodeSelector, currentJenkinsMasterPod.Spec.NodeSelector) { r.logger.Info(fmt.Sprintf("Jenkins pod node selector has changed, actual '%+v' required '%+v', recreating pod", currentJenkinsMasterPod.Spec.NodeSelector, r.jenkins.Spec.Master.NodeSelector)) diff --git a/pkg/controller/jenkins/configuration/base/reconcile_test.go b/pkg/controller/jenkins/configuration/base/reconcile_test.go index 82386155..a81aaf06 100644 --- a/pkg/controller/jenkins/configuration/base/reconcile_test.go +++ b/pkg/controller/jenkins/configuration/base/reconcile_test.go @@ -124,6 +124,29 @@ func TestGetJenkinsOpts(t *testing.T) { assert.Contains(t, opts, "httpPort") assert.Equal(t, opts["httpPort"], "8080") }) + + t.Run("JENKINS_OPTS have --httpPort=--8080 argument", func(t *testing.T) { + jenkins := &v1alpha2.Jenkins{ + Spec: v1alpha2.JenkinsSpec{ + Master: v1alpha2.JenkinsMaster{ + Containers: []v1alpha2.Container{ + { + Env: []corev1.EnvVar{ + {Name: "JENKINS_OPTS", Value: "--httpPort=--8080"}, + }, + }, + }, + }, + }, + } + + opts := GetJenkinsOpts(jenkins) + + assert.Equal(t, 1, len(opts)) + assert.NotContains(t, opts, "prefix") + assert.Contains(t, opts, "httpPort") + assert.Equal(t, opts["httpPort"], "--8080") + }) } func TestCompareContainerVolumeMounts(t *testing.T) { diff --git a/pkg/controller/jenkins/configuration/base/resources/pod.go b/pkg/controller/jenkins/configuration/base/resources/pod.go index 1a40a4b9..64d911f7 100644 --- a/pkg/controller/jenkins/configuration/base/resources/pod.go +++ b/pkg/controller/jenkins/configuration/base/resources/pod.go @@ -288,6 +288,7 @@ func NewJenkinsMasterPod(objectMeta metav1.ObjectMeta, jenkins *v1alpha2.Jenkins Containers: newContainers(jenkins), Volumes: append(GetJenkinsMasterPodBaseVolumes(jenkins), jenkins.Spec.Master.Volumes...), SecurityContext: jenkins.Spec.Master.SecurityContext, + ImagePullSecrets: jenkins.Spec.Master.ImagePullSecrets, }, } } diff --git a/pkg/controller/jenkins/configuration/base/validate.go b/pkg/controller/jenkins/configuration/base/validate.go index f3de634a..4ff7f27f 100644 --- a/pkg/controller/jenkins/configuration/base/validate.go +++ b/pkg/controller/jenkins/configuration/base/validate.go @@ -60,6 +60,47 @@ func (r *ReconcileJenkinsBaseConfiguration) Validate(jenkins *v1alpha2.Jenkins) return true, nil } +func (r *ReconcileJenkinsBaseConfiguration) validateImagePullSecrets() (bool, error) { + var err error + for _, sr := range r.jenkins.Spec.Master.ImagePullSecrets { + valid, err := r.validateImagePullSecret(sr.Name) + if err != nil || !valid { + return false, nil + } + } + return true, err +} + +func (r *ReconcileJenkinsBaseConfiguration) validateImagePullSecret(name string) (bool, error) { + secret := &corev1.Secret{} + err := r.k8sClient.Get(context.TODO(), types.NamespacedName{Name: name, Namespace: r.jenkins.ObjectMeta.Namespace}, secret) + if err != nil && apierrors.IsNotFound(err) { + r.logger.V(log.VWarn).Info(fmt.Sprintf("Secret %s not found defined in spec.master.imagePullSecrets", name)) + return false, nil + } else if err != nil && !apierrors.IsNotFound(err) { + return false, stackerr.WithStack(err) + } + + if secret.Data["docker-server"] == nil { + r.logger.V(log.VWarn).Info(fmt.Sprintf("Secret '%s' defined in spec.master.imagePullSecrets doesn't have 'docker-server' key.", name)) + return false, nil + } + if secret.Data["docker-username"] == nil { + r.logger.V(log.VWarn).Info(fmt.Sprintf("Secret '%s' defined in spec.master.imagePullSecrets doesn't have 'docker-username' key.", name)) + return false, nil + } + if secret.Data["docker-password"] == nil { + r.logger.V(log.VWarn).Info(fmt.Sprintf("Secret '%s' defined in spec.master.imagePullSecrets doesn't have 'docker-password' key.", name)) + return false, nil + } + if secret.Data["docker-email"] == nil { + r.logger.V(log.VWarn).Info(fmt.Sprintf("Secret '%s' defined in spec.master.imagePullSecrets doesn't have 'docker-email' key.", name)) + return false, nil + } + + return true, nil +} + func (r *ReconcileJenkinsBaseConfiguration) validateVolumes() (bool, error) { valid := true for _, volume := range r.jenkins.Spec.Master.Volumes { diff --git a/pkg/controller/jenkins/configuration/base/validate_test.go b/pkg/controller/jenkins/configuration/base/validate_test.go index 07b7c208..3dcb258c 100644 --- a/pkg/controller/jenkins/configuration/base/validate_test.go +++ b/pkg/controller/jenkins/configuration/base/validate_test.go @@ -132,6 +132,195 @@ func TestValidatePlugins(t *testing.T) { }) } +func TestReconcileJenkinsBaseConfiguration_validateImagePullSecrets(t *testing.T) { + t.Run("happy", func(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ref", + }, + Data: map[string][]byte{ + "docker-server": []byte("test_server"), + "docker-username": []byte("test_user"), + "docker-password": []byte("test_password"), + "docker-email": []byte("test_email"), + }, + } + + jenkins := v1alpha2.Jenkins{ + Spec: v1alpha2.JenkinsSpec{ + Master: v1alpha2.JenkinsMaster{ + ImagePullSecrets: []corev1.LocalObjectReference{ + {Name: secret.ObjectMeta.Name}, + }, + }, + }, + } + + fakeClient := fake.NewFakeClient() + err := fakeClient.Create(context.TODO(), secret) + assert.NoError(t, err) + + baseReconcileLoop := New(fakeClient, nil, logf.ZapLogger(false), + &jenkins, false, false, nil, nil) + + got, err := baseReconcileLoop.validateImagePullSecrets() + assert.Equal(t, got, true) + assert.NoError(t, err) + }) + + t.Run("no secret", func(t *testing.T) { + jenkins := v1alpha2.Jenkins{ + Spec: v1alpha2.JenkinsSpec{ + Master: v1alpha2.JenkinsMaster{ + ImagePullSecrets: []corev1.LocalObjectReference{ + {Name: "test-ref"}, + }, + }, + }, + } + + fakeClient := fake.NewFakeClient() + + baseReconcileLoop := New(fakeClient, nil, logf.ZapLogger(false), + &jenkins, false, false, nil, nil) + + got, _ := baseReconcileLoop.validateImagePullSecrets() + assert.Equal(t, got, false) + }) + + t.Run("no docker email", func(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ref", + }, + Data: map[string][]byte{ + "docker-server": []byte("test_server"), + "docker-username": []byte("test_user"), + "docker-password": []byte("test_password"), + }, + } + + jenkins := v1alpha2.Jenkins{ + Spec: v1alpha2.JenkinsSpec{ + Master: v1alpha2.JenkinsMaster{ + ImagePullSecrets: []corev1.LocalObjectReference{ + {Name: secret.ObjectMeta.Name}, + }, + }, + }, + } + + fakeClient := fake.NewFakeClient() + err := fakeClient.Create(context.TODO(), secret) + assert.NoError(t, err) + + baseReconcileLoop := New(fakeClient, nil, logf.ZapLogger(false), + &jenkins, false, false, nil, nil) + + got, _ := baseReconcileLoop.validateImagePullSecrets() + assert.Equal(t, got, false) + }) + + t.Run("no docker password", func(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ref", + }, + Data: map[string][]byte{ + "docker-server": []byte("test_server"), + "docker-username": []byte("test_user"), + "docker-email": []byte("test_email"), + }, + } + + jenkins := v1alpha2.Jenkins{ + Spec: v1alpha2.JenkinsSpec{ + Master: v1alpha2.JenkinsMaster{ + ImagePullSecrets: []corev1.LocalObjectReference{ + {Name: secret.ObjectMeta.Name}, + }, + }, + }, + } + + fakeClient := fake.NewFakeClient() + err := fakeClient.Create(context.TODO(), secret) + assert.NoError(t, err) + + baseReconcileLoop := New(fakeClient, nil, logf.ZapLogger(false), + &jenkins, false, false, nil, nil) + + got, _ := baseReconcileLoop.validateImagePullSecrets() + assert.Equal(t, got, false) + }) + + t.Run("no docker username", func(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ref", + }, + Data: map[string][]byte{ + "docker-server": []byte("test_server"), + "docker-password": []byte("test_password"), + "docker-email": []byte("test_email"), + }, + } + + jenkins := v1alpha2.Jenkins{ + Spec: v1alpha2.JenkinsSpec{ + Master: v1alpha2.JenkinsMaster{ + ImagePullSecrets: []corev1.LocalObjectReference{ + {Name: secret.ObjectMeta.Name}, + }, + }, + }, + } + + fakeClient := fake.NewFakeClient() + err := fakeClient.Create(context.TODO(), secret) + assert.NoError(t, err) + + baseReconcileLoop := New(fakeClient, nil, logf.ZapLogger(false), + &jenkins, false, false, nil, nil) + + got, _ := baseReconcileLoop.validateImagePullSecrets() + assert.Equal(t, got, false) + }) + + t.Run("no docker server", func(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ref", + }, + Data: map[string][]byte{ + "docker-username": []byte("test_user"), + "docker-password": []byte("test_password"), + "docker-email": []byte("test_email"), + }, + } + + jenkins := v1alpha2.Jenkins{ + Spec: v1alpha2.JenkinsSpec{ + Master: v1alpha2.JenkinsMaster{ + ImagePullSecrets: []corev1.LocalObjectReference{ + {Name: secret.ObjectMeta.Name}, + }, + }, + }, + } + + fakeClient := fake.NewFakeClient() + err := fakeClient.Create(context.TODO(), secret) + assert.NoError(t, err) + + baseReconcileLoop := New(fakeClient, nil, logf.ZapLogger(false), + &jenkins, false, false, nil, nil) + + got, _ := baseReconcileLoop.validateImagePullSecrets() + assert.Equal(t, got, false) + }) +} + func TestValidateJenkinsMasterPodEnvs(t *testing.T) { t.Run("happy", func(t *testing.T) { jenkins := v1alpha2.Jenkins{