├── .gitignore ├── .travis.yml ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── Makefile ├── README.md ├── VERSION.txt ├── assets ├── jenkins-credentials.png ├── jenkins-operator-draft.png ├── jenkins-seed.png ├── jenkins.png ├── phases.png └── reconcile.png ├── build └── Dockerfile ├── checkmake.ini ├── cicd ├── jobs │ └── build.jenkins └── pipelines │ └── build.jenkins ├── cmd └── manager │ └── main.go ├── config.env ├── deploy ├── crds │ ├── virtuslab_v1alpha1_jenkins_cr.yaml │ └── virtuslab_v1alpha1_jenkins_crd.yaml ├── operator.yaml ├── role.yaml ├── role_binding.yaml ├── seed_jobs_secret.yaml └── service_account.yaml ├── docs ├── developer-guide.md ├── getting-started.md ├── how-it-works.md ├── installation.md └── security.md ├── pkg ├── apis │ ├── addtoscheme_virtuslab_v1alpha1.go │ ├── apis.go │ └── virtuslab │ │ └── v1alpha1 │ │ ├── doc.go │ │ ├── jenkins_types.go │ │ ├── register.go │ │ └── zz_generated.deepcopy.go ├── controller │ └── jenkins │ │ ├── backup │ │ ├── aws │ │ │ ├── s3.go │ │ │ └── s3_test.go │ │ ├── backup.go │ │ └── nobackup │ │ │ └── nobackup.go │ │ ├── client │ │ ├── doc.go │ │ ├── jenkins.go │ │ ├── mockgen.go │ │ └── token.go │ │ ├── configuration │ │ ├── base │ │ │ ├── doc.go │ │ │ ├── reconcile.go │ │ │ ├── reconcile_test.go │ │ │ ├── resources.go │ │ │ ├── resources │ │ │ │ ├── backup_credentials_secret.go │ │ │ │ ├── base_configuration_configmap.go │ │ │ │ ├── doc.go │ │ │ │ ├── init_configuration_configmap.go │ │ │ │ ├── meta.go │ │ │ │ ├── operator_credentials_secret.go │ │ │ │ ├── pod.go │ │ │ │ ├── random.go │ │ │ │ ├── rbac.go │ │ │ │ ├── render.go │ │ │ │ ├── scripts_configmap.go │ │ │ │ ├── service.go │ │ │ │ ├── service_account.go │ │ │ │ └── user_configuration_configmap.go │ │ │ ├── validate.go │ │ │ └── validate_test.go │ │ └── user │ │ │ ├── doc.go │ │ │ ├── reconcile.go │ │ │ ├── seedjobs │ │ │ ├── doc.go │ │ │ ├── seedjobs.go │ │ │ └── seedjobs_test.go │ │ │ ├── validate.go │ │ │ └── validate_test.go │ │ ├── constants │ │ ├── constants.go │ │ └── labels.go │ │ ├── groovy │ │ ├── doc.go │ │ └── groovy.go │ │ ├── handler.go │ │ ├── jenkins_controller.go │ │ ├── jobs │ │ ├── doc.go │ │ ├── jobs.go │ │ └── jobs_test.go │ │ └── plugins │ │ ├── base_plugins.go │ │ ├── plugin.go │ │ └── plugin_test.go ├── event │ └── event.go └── log │ └── log.go ├── test └── e2e │ ├── aws_s3_backup_test.go │ ├── base_configuration_test.go │ ├── jenkins.go │ ├── main_test.go │ ├── restart_pod_test.go │ ├── user_configuration_test.go │ └── wait.go └── version └── version.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | vendor 3 | deploy/namespace-init.yaml 4 | 5 | # Temporary Build Files 6 | build/_output 7 | build/_test 8 | # Created by https://www.gitignore.io/api/go,vim,emacs,visualstudiocode 9 | 10 | ### Emacs ### 11 | # -*- mode: gitignore; -*- 12 | *~ 13 | \#*\# 14 | /.emacs.desktop 15 | /.emacs.desktop.lock 16 | *.elc 17 | auto-save-list 18 | tramp 19 | .\#* 20 | # Org-mode 21 | .org-id-locations 22 | *_archive 23 | # flymake-mode 24 | *_flymake.* 25 | # eshell files 26 | /eshell/history 27 | /eshell/lastdir 28 | # elpa packages 29 | /elpa/ 30 | # reftex files 31 | *.rel 32 | # AUCTeX auto folder 33 | /auto/ 34 | # cask packages 35 | .cask/ 36 | dist/ 37 | # Flycheck 38 | flycheck_*.el 39 | # server auth directory 40 | /server/ 41 | # projectiles files 42 | .projectile 43 | projectile-bookmarks.eld 44 | # directory configuration 45 | .dir-locals.el 46 | # saveplace 47 | places 48 | # url cache 49 | url/cache/ 50 | # cedet 51 | ede-projects.el 52 | # smex 53 | smex-items 54 | # company-statistics 55 | company-statistics-cache.el 56 | # anaconda-mode 57 | anaconda-mode/ 58 | 59 | ### Go ### 60 | # Binaries for programs and plugins 61 | *.exe 62 | *.exe~ 63 | *.dll 64 | *.so 65 | *.dylib 66 | # Test binary, build with 'go test -c' 67 | *.test 68 | # Output of the go coverage tool, specifically when used with LiteIDE 69 | *.out 70 | 71 | ### Vim ### 72 | # swap 73 | .sw[a-p] 74 | .*.sw[a-p] 75 | # session 76 | Session.vim 77 | # temporary 78 | .netrwhist 79 | # auto-generated tag files 80 | tags 81 | 82 | ### VisualStudioCode ### 83 | .vscode/* 84 | .history 85 | # End of https://www.gitignore.io/api/go,vim,emacs,visualstudiocode 86 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | sudo: false 3 | 4 | go: 5 | - 1.10.x 6 | - 1.11.x 7 | - master 8 | 9 | matrix: 10 | fast_finish: true 11 | allow_failures: 12 | - go: master 13 | 14 | before_install: 15 | - go get golang.org/x/lint/golint 16 | - go get honnef.co/go/tools/cmd/staticcheck 17 | - go get -u github.com/golang/dep/cmd/dep 18 | - make go-dependencies 19 | 20 | script: 21 | - make verify 22 | 23 | cache: 24 | directories: 25 | - vendor -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Force dep to vendor the code generators, which aren't imported just used at dev time. 2 | required = [ 3 | "k8s.io/code-generator/cmd/defaulter-gen", 4 | "k8s.io/code-generator/cmd/deepcopy-gen", 5 | "k8s.io/code-generator/cmd/conversion-gen", 6 | "k8s.io/code-generator/cmd/client-gen", 7 | "k8s.io/code-generator/cmd/lister-gen", 8 | "k8s.io/code-generator/cmd/informer-gen", 9 | "k8s.io/code-generator/cmd/openapi-gen", 10 | "k8s.io/gengo/args", 11 | ] 12 | 13 | [[override]] 14 | name = "k8s.io/code-generator" 15 | # revision for tag "kubernetes-1.11.2" 16 | revision = "6702109cc68eb6fe6350b83e14407c8d7309fd1a" 17 | 18 | [[override]] 19 | name = "k8s.io/api" 20 | # revision for tag "kubernetes-1.11.2" 21 | revision = "2d6f90ab1293a1fb871cf149423ebb72aa7423aa" 22 | 23 | [[override]] 24 | name = "k8s.io/apiextensions-apiserver" 25 | # revision for tag "kubernetes-1.11.2" 26 | revision = "408db4a50408e2149acbd657bceb2480c13cb0a4" 27 | 28 | [[override]] 29 | name = "k8s.io/apimachinery" 30 | # revision for tag "kubernetes-1.11.2" 31 | revision = "103fd098999dc9c0c88536f5c9ad2e5da39373ae" 32 | 33 | [[override]] 34 | name = "k8s.io/client-go" 35 | # revision for tag "kubernetes-1.11.2" 36 | revision = "1f13a808da65775f22cbf47862c4e5898d8f4ca1" 37 | 38 | [[override]] 39 | name = "sigs.k8s.io/controller-runtime" 40 | version = "v0.1.4" 41 | 42 | [[override]] 43 | name = "github.com/bndr/gojenkins" 44 | revision = "de43c03cf849dd63a9737df6e05791c7a176c93d" 45 | 46 | [[constraint]] 47 | name = "github.com/operator-framework/operator-sdk" 48 | # The version rule is used for a specific release and the master branch for in between releases. 49 | # branch = "v0.2.x" #osdk_branch_annotation 50 | version = "=v0.2.0" #osdk_version_annotation 51 | 52 | [prune] 53 | go-tests = true 54 | non-go = true 55 | 56 | [[prune.project]] 57 | name = "k8s.io/code-generator" 58 | non-go = false 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This project **was moved** to the official Jenkins organization [jenkinsci/kubernetes-operator](https://github.com/jenkinsci/kubernetes-operator) -------------------------------------------------------------------------------- /VERSION.txt: -------------------------------------------------------------------------------- 1 | v0.0.3 2 | -------------------------------------------------------------------------------- /assets/jenkins-credentials.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VirtusLab/jenkins-operator/297a7874929dce44f6ce79a6735e0a6788575a95/assets/jenkins-credentials.png -------------------------------------------------------------------------------- /assets/jenkins-operator-draft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VirtusLab/jenkins-operator/297a7874929dce44f6ce79a6735e0a6788575a95/assets/jenkins-operator-draft.png -------------------------------------------------------------------------------- /assets/jenkins-seed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VirtusLab/jenkins-operator/297a7874929dce44f6ce79a6735e0a6788575a95/assets/jenkins-seed.png -------------------------------------------------------------------------------- /assets/jenkins.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VirtusLab/jenkins-operator/297a7874929dce44f6ce79a6735e0a6788575a95/assets/jenkins.png -------------------------------------------------------------------------------- /assets/phases.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VirtusLab/jenkins-operator/297a7874929dce44f6ce79a6735e0a6788575a95/assets/phases.png -------------------------------------------------------------------------------- /assets/reconcile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VirtusLab/jenkins-operator/297a7874929dce44f6ce79a6735e0a6788575a95/assets/reconcile.png -------------------------------------------------------------------------------- /build/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.8 2 | 3 | USER nobody 4 | 5 | ADD build/_output/bin/jenkins-operator /usr/local/bin/jenkins-operator 6 | 7 | CMD [ "/usr/local/bin/jenkins-operator" ] 8 | -------------------------------------------------------------------------------- /checkmake.ini: -------------------------------------------------------------------------------- 1 | [maxbodylength] 2 | maxBodyLength = 10 -------------------------------------------------------------------------------- /cicd/jobs/build.jenkins: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env groovy 2 | 3 | pipelineJob('build-jenkins-operator') { 4 | displayName('Build jenkins-operator') 5 | 6 | logRotator { 7 | numToKeep(10) 8 | daysToKeep(30) 9 | } 10 | 11 | configure { project -> 12 | project / 'properties' / 'org.jenkinsci.plugins.workflow.job.properties.DurabilityHintJobProperty' { 13 | hint('PERFORMANCE_OPTIMIZED') 14 | } 15 | } 16 | 17 | definition { 18 | cpsScm { 19 | scm { 20 | git { 21 | remote { 22 | url('https://github.com/VirtusLab/jenkins-operator.git') 23 | credentials('jenkins-operator') 24 | } 25 | branches('*/master') 26 | } 27 | } 28 | scriptPath('cicd/pipelines/build.jenkins') 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /cicd/pipelines/build.jenkins: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env groovy 2 | 3 | def label = "build-jenkins-operator-${UUID.randomUUID().toString()}" 4 | def home = "/home/jenkins" 5 | def workspace = "${home}/workspace/build-jenkins-operator" 6 | def workdir = "${workspace}/src/github.com/VirtusLab/jenkins-operator/" 7 | 8 | podTemplate(label: label, 9 | containers: [ 10 | containerTemplate(name: 'jnlp', image: 'jenkins/jnlp-slave:alpine'), 11 | containerTemplate(name: 'go', image: 'golang:1-alpine', command: 'cat', ttyEnabled: true), 12 | ]) { 13 | 14 | node(label) { 15 | dir(workdir) { 16 | stage('Init') { 17 | timeout(time: 3, unit: 'MINUTES') { 18 | checkout scm 19 | } 20 | container('go') { 21 | sh 'apk --no-cache --update add make git gcc libc-dev' 22 | } 23 | } 24 | 25 | stage('Test') { 26 | container('go') { 27 | sh 'make test' 28 | } 29 | } 30 | 31 | stage('Build') { 32 | container('go') { 33 | sh 'make build' 34 | } 35 | } 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /cmd/manager/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "runtime" 9 | 10 | "github.com/VirtusLab/jenkins-operator/pkg/apis" 11 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins" 12 | "github.com/VirtusLab/jenkins-operator/pkg/event" 13 | "github.com/VirtusLab/jenkins-operator/pkg/log" 14 | "github.com/VirtusLab/jenkins-operator/version" 15 | 16 | "github.com/operator-framework/operator-sdk/pkg/k8sutil" 17 | "github.com/operator-framework/operator-sdk/pkg/leader" 18 | "github.com/operator-framework/operator-sdk/pkg/ready" 19 | sdkVersion "github.com/operator-framework/operator-sdk/version" 20 | _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" 21 | "sigs.k8s.io/controller-runtime/pkg/client/config" 22 | "sigs.k8s.io/controller-runtime/pkg/manager" 23 | "sigs.k8s.io/controller-runtime/pkg/runtime/signals" 24 | ) 25 | 26 | func printInfo() { 27 | log.Log.Info(fmt.Sprintf("Version: %s", version.Version)) 28 | log.Log.Info(fmt.Sprintf("Git commit: %s", version.GitCommit)) 29 | log.Log.Info(fmt.Sprintf("Go Version: %s", runtime.Version())) 30 | log.Log.Info(fmt.Sprintf("Go OS/Arch: %s/%s", runtime.GOOS, runtime.GOARCH)) 31 | log.Log.Info(fmt.Sprintf("operator-sdk Version: %v", sdkVersion.Version)) 32 | } 33 | 34 | func main() { 35 | minikube := flag.Bool("minikube", false, "Use minikube as a Kubernetes platform") 36 | local := flag.Bool("local", false, "Run operator locally") 37 | debug := flag.Bool("debug", false, "Set log level to debug") 38 | flag.Parse() 39 | 40 | log.SetupLogger(debug) 41 | printInfo() 42 | 43 | namespace, err := k8sutil.GetWatchNamespace() 44 | if err != nil { 45 | fatal(err, "failed to get watch namespace") 46 | } 47 | log.Log.Info(fmt.Sprintf("watch namespace: %v", namespace)) 48 | 49 | // get a config to talk to the apiserver 50 | cfg, err := config.GetConfig() 51 | if err != nil { 52 | fatal(err, "failed to get config") 53 | } 54 | 55 | // become the leader before proceeding 56 | err = leader.Become(context.TODO(), "jenkins-operator-lock") 57 | if err != nil { 58 | fatal(err, "failed to become leader") 59 | } 60 | 61 | r := ready.NewFileReady() 62 | err = r.Set() 63 | if err != nil { 64 | fatal(err, "failed to get ready.NewFileReady") 65 | } 66 | defer func() { 67 | _ = r.Unset() 68 | }() 69 | 70 | // create a new Cmd to provide shared dependencies and start components 71 | mgr, err := manager.New(cfg, manager.Options{Namespace: namespace}) 72 | if err != nil { 73 | fatal(err, "failed to create manager") 74 | } 75 | 76 | log.Log.Info("Registering Components.") 77 | 78 | // setup Scheme for all resources 79 | if err := apis.AddToScheme(mgr.GetScheme()); err != nil { 80 | fatal(err, "failed to setup scheme") 81 | } 82 | 83 | // setup events 84 | events, err := event.New(cfg) 85 | if err != nil { 86 | fatal(err, "failed to create manager") 87 | } 88 | 89 | // setup Jenkins controller 90 | if err := jenkins.Add(mgr, *local, *minikube, events); err != nil { 91 | fatal(err, "failed to setup controllers") 92 | } 93 | 94 | log.Log.Info("Starting the Cmd.") 95 | 96 | // start the Cmd 97 | if err := mgr.Start(signals.SetupSignalHandler()); err != nil { 98 | fatal(err, "failed to start cmd") 99 | } 100 | } 101 | 102 | func fatal(err error, message string) { 103 | log.Log.Error(err, message) 104 | os.Exit(-1) 105 | } 106 | -------------------------------------------------------------------------------- /config.env: -------------------------------------------------------------------------------- 1 | # Setup variables for the Makefile 2 | NAME=jenkins-operator 3 | PKG=github.com/VirtusLab/jenkins-operator 4 | DOCKER_REGISTRY=virtuslab 5 | REPO=jenkins-operator 6 | NAMESPACE=default 7 | API_VERSION=jenkins:v1alpha1 8 | MINIKUBE_KUBERNETES_VERSION=v1.10.9 9 | MINIKUBE_DRIVER=virtualbox 10 | ENVIRONMENT=minikube -------------------------------------------------------------------------------- /deploy/crds/virtuslab_v1alpha1_jenkins_cr.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: virtuslab.com/v1alpha1 2 | kind: Jenkins 3 | metadata: 4 | name: example 5 | spec: 6 | master: 7 | image: jenkins/jenkins:lts 8 | seedJobs: 9 | - id: jenkins-operator 10 | targets: "cicd/jobs/*.jenkins" 11 | description: "Jenkins Operator repository" 12 | repositoryBranch: master 13 | repositoryUrl: https://github.com/VirtusLab/jenkins-operator.git 14 | -------------------------------------------------------------------------------- /deploy/crds/virtuslab_v1alpha1_jenkins_crd.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1beta1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: jenkins.virtuslab.com 5 | spec: 6 | group: virtuslab.com 7 | names: 8 | kind: Jenkins 9 | listKind: JenkinsList 10 | plural: jenkins 11 | singular: jenkins 12 | scope: Namespaced 13 | version: v1alpha1 14 | -------------------------------------------------------------------------------- /deploy/operator.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: jenkins-operator 6 | spec: 7 | replicas: 1 8 | selector: 9 | matchLabels: 10 | name: jenkins-operator 11 | template: 12 | metadata: 13 | labels: 14 | name: jenkins-operator 15 | spec: 16 | serviceAccountName: jenkins-operator 17 | containers: 18 | - name: jenkins-operator 19 | image: virtuslab/jenkins-operator:v0.0.3 20 | ports: 21 | - containerPort: 60000 22 | name: metrics 23 | command: 24 | - jenkins-operator 25 | args: [] 26 | imagePullPolicy: IfNotPresent 27 | env: 28 | - name: WATCH_NAMESPACE 29 | valueFrom: 30 | fieldRef: 31 | fieldPath: metadata.namespace 32 | - name: POD_NAME 33 | valueFrom: 34 | fieldRef: 35 | fieldPath: metadata.name 36 | - name: OPERATOR_NAME 37 | value: "jenkins-operator" 38 | -------------------------------------------------------------------------------- /deploy/role.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: Role 3 | apiVersion: rbac.authorization.k8s.io/v1 4 | metadata: 5 | name: jenkins-operator 6 | rules: 7 | - apiGroups: 8 | - virtuslab.com 9 | resources: 10 | - '*' 11 | verbs: 12 | - '*' 13 | - apiGroups: 14 | - "" 15 | resources: 16 | - services 17 | - configmaps 18 | - secrets 19 | verbs: 20 | - get 21 | - create 22 | - update 23 | - list 24 | - watch 25 | - apiGroups: 26 | - "extensions" 27 | resources: 28 | - ingresses 29 | verbs: 30 | - create 31 | - update 32 | - apiGroups: 33 | - "" 34 | resources: 35 | - serviceaccounts 36 | verbs: 37 | - create 38 | - apiGroups: 39 | - rbac.authorization.k8s.io 40 | resources: 41 | - roles 42 | - rolebindings 43 | verbs: 44 | - create 45 | - update 46 | - apiGroups: 47 | - "" 48 | resources: 49 | - pods/portforward 50 | verbs: 51 | - create 52 | - apiGroups: 53 | - "" 54 | resources: 55 | - pods/log 56 | verbs: 57 | - get 58 | - list 59 | - watch 60 | - apiGroups: 61 | - "" 62 | resources: 63 | - pods 64 | - pods/exec 65 | verbs: 66 | - "*" 67 | -------------------------------------------------------------------------------- /deploy/role_binding.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: RoleBinding 3 | apiVersion: rbac.authorization.k8s.io/v1 4 | metadata: 5 | name: jenkins-operator 6 | subjects: 7 | - kind: ServiceAccount 8 | name: jenkins-operator 9 | roleRef: 10 | kind: Role 11 | name: jenkins-operator 12 | apiGroup: rbac.authorization.k8s.io 13 | -------------------------------------------------------------------------------- /deploy/seed_jobs_secret.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | name: deploy-keys 6 | data: 7 | jenkins-operator-e2e: | 8 | REDACTED -------------------------------------------------------------------------------- /deploy/service_account.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: jenkins-operator 6 | -------------------------------------------------------------------------------- /docs/developer-guide.md: -------------------------------------------------------------------------------- 1 | # Developer guide 2 | 3 | This document explains how to setup your development environment. 4 | 5 | ## Prerequisites 6 | 7 | - [operator_sdk][operator_sdk] 8 | - [dep][dep_tool] version v0.5.0+ 9 | - [git][git_tool] 10 | - [go][go_tool] version v1.10+ 11 | - [minikube][minikube] version v0.31.0+ (preferred Hypervisor - [virtualbox][virtualbox]) 12 | - [docker][docker_tool] version 17.03+ 13 | 14 | ## Clone repository and download dependencies 15 | 16 | ```bash 17 | mkdir -p $GOPATH/src/github.com/VirtusLab 18 | cd $GOPATH/src/github.com/VirtusLab/ 19 | git clone git@github.com:VirtusLab/jenkins-operator.git 20 | cd jenkins-operator 21 | make go-dependencies 22 | ``` 23 | 24 | ## Build and run 25 | 26 | Build and run **jenkins-operator** locally: 27 | 28 | ```bash 29 | make build && make minikube-run EXTRA_ARGS='--minikube --local' 30 | ``` 31 | 32 | Once minikube and **jenkins-operator** are up and running, apply Jenkins custom resource: 33 | 34 | ```bash 35 | kubectl apply -f deploy/crds/virtuslab_v1alpha1_jenkins_cr.yaml 36 | kubectl get jenkins -o yaml 37 | kubectl get po 38 | ``` 39 | 40 | ## Testing 41 | 42 | Run unit tests: 43 | 44 | ```bash 45 | make test 46 | ``` 47 | 48 | Run e2e tests with minikube: 49 | 50 | ```bash 51 | make start-minikube 52 | eval $(minikube docker-env) 53 | make e2e 54 | ``` 55 | 56 | ## Tips & Tricks 57 | 58 | ### Building docker image on minikube (for e2e tests) 59 | 60 | To be able to work with the docker daemon on `minikube` machine run the following command before building an image: 61 | 62 | ```bash 63 | eval $(minikube docker-env) 64 | ``` 65 | 66 | ### When `pkg/apis/virtuslab/v1alpha1/jenkins_types.go` has changed 67 | 68 | Run: 69 | 70 | ```bash 71 | make deepcopy-gen 72 | ``` 73 | 74 | ### Getting Jenkins URL and basic credentials 75 | 76 | ```bash 77 | minikube service jenkins-operator-example --url 78 | kubectl get secret jenkins-operator-credentials-example -o 'jsonpath={.data.user}' | base64 -d 79 | kubectl get secret jenkins-operator-credentials-example -o 'jsonpath={.data.password}' | base64 -d 80 | ``` 81 | 82 | 83 | [dep_tool]:https://golang.github.io/dep/docs/installation.html 84 | [git_tool]:https://git-scm.com/downloads 85 | [go_tool]:https://golang.org/dl/ 86 | [operator_sdk]:https://github.com/operator-framework/operator-sdk 87 | [fork_guide]:https://help.github.com/articles/fork-a-repo/ 88 | [docker_tool]:https://docs.docker.com/install/ 89 | [kubectl_tool]:https://kubernetes.io/docs/tasks/tools/install-kubectl/ 90 | [minikube]:https://kubernetes.io/docs/tasks/tools/install-minikube/ 91 | [virtualbox]:https://www.virtualbox.org/wiki/Downloads 92 | [jenkins-operator]:../README.md -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This document describes a getting started guide for **jenkins-operator** and an additional configuration. 4 | 5 | 1. [First Steps](#first-steps) 6 | 2. [Deploy Jenkins](#deploy-jenkins) 7 | 3. [Configure Seed Jobs and Pipelines](#configure-seed-jobs-and-pipelines) 8 | 4. [Install Plugins](#install-plugins) 9 | 5. [Configure Authorization](#configure-authorization) 10 | 6. [Configure Backup & Restore](#configure-backup-&-restore) 11 | 7. [Debugging](#debugging) 12 | 13 | ## First Steps 14 | 15 | Prepare your Kubernetes cluster and set up access. 16 | Once you have running Kubernetes cluster you can focus on installing **jenkins-operator** according to the [Installation](installation.md) guide. 17 | 18 | ## Deploy Jenkins 19 | 20 | Once jenkins-operator is up and running let's deploy actual Jenkins instance. 21 | Let's use example below: 22 | 23 | ```bash 24 | apiVersion: virtuslab.com/v1alpha1 25 | kind: Jenkins 26 | metadata: 27 | name: example 28 | spec: 29 | master: 30 | image: jenkins/jenkins 31 | seedJobs: 32 | - id: jenkins-operator 33 | targets: "cicd/jobs/*.jenkins" 34 | description: "Jenkins Operator repository" 35 | repositoryBranch: master 36 | repositoryUrl: https://github.com/VirtusLab/jenkins-operator.git 37 | ``` 38 | 39 | Watch Jenkins instance being created: 40 | 41 | ```bash 42 | kubectl get pods -w 43 | ``` 44 | 45 | Get Jenkins credentials: 46 | 47 | ```bash 48 | kubectl get secret jenkins-operator-credentials-example -o 'jsonpath={.data.user}' | base64 -d 49 | kubectl get secret jenkins-operator-credentials-example -o 'jsonpath={.data.password}' | base64 -d 50 | ``` 51 | 52 | Connect to Jenkins (minikube): 53 | 54 | ```bash 55 | minikube service jenkins-operator-example --url 56 | ``` 57 | Pick up the first URL. 58 | 59 | Connect to Jenkins (actual Kubernetes cluster): 60 | 61 | ```bash 62 | kubectl describe svc jenkins-operator-example 63 | kubectl port-forward jenkins-operator-example 8080:8080 64 | ``` 65 | Then open browser with address http://localhost:8080. 66 | ![jenkins](../assets/jenkins.png) 67 | 68 | ## Configure Seed Jobs and Pipelines 69 | 70 | Jenkins operator uses [job-dsl][job-dsl] and [ssh-credentials][ssh-credentials] plugins for configuring jobs 71 | and deploy keys. 72 | 73 | ## Prepare job definitions and pipelines 74 | 75 | First you have to prepare pipelines and job definition in your GitHub repository using the following structure: 76 | 77 | ``` 78 | cicd/ 79 | ├── jobs 80 | │   └── build.jenkins 81 | └── pipelines 82 | └── build.jenkins 83 | ``` 84 | 85 | **cicd/jobs/build.jenkins** it's a job definition: 86 | 87 | ``` 88 | #!/usr/bin/env groovy 89 | 90 | pipelineJob('build-jenkins-operator') { 91 | displayName('Build jenkins-operator') 92 | 93 | definition { 94 | cpsScm { 95 | scm { 96 | git { 97 | remote { 98 | url('https://github.com/VirtusLab/jenkins-operator.git') 99 | credentials('jenkins-operator') 100 | } 101 | branches('*/master') 102 | } 103 | } 104 | scriptPath('cicd/pipelines/build.jenkins') 105 | } 106 | } 107 | } 108 | ``` 109 | 110 | **cicd/jobs/build.jenkins** it's an actual Jenkins pipeline: 111 | 112 | ``` 113 | #!/usr/bin/env groovy 114 | 115 | def label = "build-jenkins-operator-${UUID.randomUUID().toString()}" 116 | def home = "/home/jenkins" 117 | def workspace = "${home}/workspace/build-jenkins-operator" 118 | def workdir = "${workspace}/src/github.com/VirtusLab/jenkins-operator/" 119 | 120 | podTemplate(label: label, 121 | containers: [ 122 | containerTemplate(name: 'jnlp', image: 'jenkins/jnlp-slave:alpine'), 123 | containerTemplate(name: 'go', image: 'golang:1-alpine', command: 'cat', ttyEnabled: true), 124 | ]) { 125 | 126 | node(label) { 127 | dir(workdir) { 128 | stage('Init') { 129 | timeout(time: 3, unit: 'MINUTES') { 130 | checkout scm 131 | } 132 | container('go') { 133 | sh 'apk --no-cache --update add make git gcc libc-dev' 134 | } 135 | } 136 | 137 | stage('Build') { 138 | container('go') { 139 | sh 'make build' 140 | } 141 | } 142 | } 143 | } 144 | } 145 | ``` 146 | 147 | ## Configure Seed Jobs 148 | 149 | Jenkins Seed Jobs are configured using `Jenkins.spec.seedJobs` section from your custom resource manifest: 150 | 151 | ``` 152 | apiVersion: virtuslab.com/v1alpha1 153 | kind: Jenkins 154 | metadata: 155 | name: example 156 | spec: 157 | master: 158 | image: jenkins/jenkins:lts 159 | seedJobs: 160 | - id: jenkins-operator 161 | targets: "cicd/jobs/*.jenkins" 162 | description: "Jenkins Operator repository" 163 | repositoryBranch: master 164 | repositoryUrl: https://github.com/VirtusLab/jenkins-operator.git 165 | ``` 166 | 167 | If your GitHub repository is **private** you have to configure corresponding **privateKey** and Kubernetes Secret: 168 | 169 | ``` 170 | apiVersion: virtuslab.com/v1alpha1 171 | kind: Jenkins 172 | metadata: 173 | name: example 174 | spec: 175 | master: 176 | image: jenkins/jenkins:lts 177 | seedJobs: 178 | - id: jenkins-operator 179 | targets: "cicd/jobs/*.jenkins" 180 | description: "Jenkins Operator repository" 181 | repositoryBranch: master 182 | repositoryUrl: git@github.com:VirtusLab/jenkins-operator.git 183 | privateKey: 184 | secretKeyRef: 185 | name: deploy-keys 186 | key: jenkins-operator 187 | ``` 188 | 189 | And Kubernetes Secret: 190 | 191 | ``` 192 | apiVersion: v1 193 | kind: Secret 194 | metadata: 195 | name: deploy-keys 196 | data: 197 | jenkins-operator-e2e: | 198 | -----BEGIN RSA PRIVATE KEY----- 199 | MIIJKAIBAAKCAgEAxxDpleJjMCN5nusfW/AtBAZhx8UVVlhhhIKXvQ+dFODQIdzO 200 | oDXybs1zVHWOj31zqbbJnsfsVZ9Uf3p9k6xpJ3WFY9b85WasqTDN1xmSd6swD4N8 201 | ... 202 | ``` 203 | 204 | **jenkins-operator** will automatically discover and configure all seed jobs. 205 | 206 | You can verify if deploy keys were successfully configured in Jenkins **Credentials** tab. 207 | 208 | ![jenkins](../assets/jenkins-credentials.png) 209 | 210 | You can verify if your pipelines were successfully configured in Jenkins Seed Job console output. 211 | 212 | ![jenkins](../assets/jenkins-seed.png) 213 | 214 | ## Jenkins Customisation 215 | 216 | Jenkins can be customized using groovy scripts or configuration as code plugin. All custom configuration is stored in 217 | the **jenkins-operator-user-configuration-example** ConfigMap which is automatically created by **jenkins-operator**. 218 | 219 | ``` 220 | kubectl get configmap jenkins-operator-user-configuration-example -o yaml 221 | 222 | apiVersion: v1 223 | data: 224 | 1-configure-theme.groovy: |2 225 | 226 | import jenkins.* 227 | import jenkins.model.* 228 | import hudson.* 229 | import hudson.model.* 230 | import org.jenkinsci.plugins.simpletheme.ThemeElement 231 | import org.jenkinsci.plugins.simpletheme.CssTextThemeElement 232 | import org.jenkinsci.plugins.simpletheme.CssUrlThemeElement 233 | 234 | Jenkins jenkins = Jenkins.getInstance() 235 | 236 | def decorator = Jenkins.instance.getDescriptorByType(org.codefirst.SimpleThemeDecorator.class) 237 | 238 | List configElements = new ArrayList<>(); 239 | configElements.add(new CssTextThemeElement("DEFAULT")); 240 | configElements.add(new CssUrlThemeElement("https://cdn.rawgit.com/afonsof/jenkins-material-theme/gh-pages/dist/material-light-green.css")); 241 | decorator.setElements(configElements); 242 | decorator.save(); 243 | 244 | jenkins.save() 245 | kind: ConfigMap 246 | metadata: 247 | labels: 248 | app: jenkins-operator 249 | jenkins-cr: example 250 | watch: "true" 251 | name: jenkins-operator-user-configuration-example 252 | namespace: default 253 | ``` 254 | 255 | When **jenkins-operator-user-configuration-example** ConfigMap is updated Jenkins automatically runs the **jenkins-operator-user-configuration** Jenkins Job which executes all scripts. 256 | 257 | ## Install Plugins 258 | 259 | To install a plugin please add **2-install-slack-plugin.groovy** script to the **jenkins-operator-user-configuration-example** ConfigMap: 260 | 261 | ``` 262 | apiVersion: v1 263 | data: 264 | 1-configure-theme.groovy: |2 265 | 266 | import jenkins.* 267 | import jenkins.model.* 268 | import hudson.* 269 | import hudson.model.* 270 | import org.jenkinsci.plugins.simpletheme.ThemeElement 271 | import org.jenkinsci.plugins.simpletheme.CssTextThemeElement 272 | import org.jenkinsci.plugins.simpletheme.CssUrlThemeElement 273 | 274 | Jenkins jenkins = Jenkins.getInstance() 275 | 276 | def decorator = Jenkins.instance.getDescriptorByType(org.codefirst.SimpleThemeDecorator.class) 277 | 278 | List configElements = new ArrayList<>(); 279 | configElements.add(new CssTextThemeElement("DEFAULT")); 280 | configElements.add(new CssUrlThemeElement("https://cdn.rawgit.com/afonsof/jenkins-material-theme/gh-pages/dist/material-light-green.css")); 281 | decorator.setElements(configElements); 282 | decorator.save(); 283 | 284 | jenkins.save() 285 | 2-install-slack-plugin.groovy: |2 286 | 287 | import jenkins.model.* 288 | import java.util.logging.Level 289 | import java.util.logging.Logger 290 | 291 | def instance = Jenkins.getInstance() 292 | def plugins = instance.getPluginManager() 293 | def updateCenter = instance.getUpdateCenter() 294 | def hasInstalledPlugins = false 295 | 296 | Logger logger = Logger.getLogger('jenkins.instance.restart') 297 | 298 | if (!plugins.getPlugin("slack")) { 299 | logger.log(Level.INFO, "Installing plugin: slack") 300 | 301 | updateCenter.updateAllSites() 302 | def plugin = updateCenter.getPlugin("slack") 303 | def installResult = plugin.deploy() 304 | while (!installResult.isDone()) sleep(10) 305 | hasInstalledPlugins = true 306 | instance.save() 307 | } 308 | 309 | if (hasInstalledPlugins) { 310 | logger.log(Level.INFO, "Successfully installed slack plugin, restarting ...") 311 | // Queue a restart of the instance 312 | instance.save() 313 | instance.doSafeRestart(null) 314 | } else { 315 | logger.log(Level.INFO, "No plugins need installing.") 316 | } 317 | ``` 318 | 319 | Then **jenkins-operator** will automatically trigger **jenkins-operator-user-configuration** Jenkins Job again. 320 | 321 | ## Configure Backup & Restore (work in progress) 322 | 323 | Not implemented yet. 324 | 325 | ## Debugging 326 | 327 | Turn on debug in **jenkins-operator** deployment: 328 | 329 | ```bash 330 | sed -i 's|\(args:\).*|\1\ ["--debug"\]|' deploy/operator.yaml 331 | kubectl apply -f deploy/operator.yaml 332 | ``` 333 | 334 | Watch Kubernetes events: 335 | 336 | ```bash 337 | kubectl get events --sort-by='{.lastTimestamp}' 338 | ``` 339 | 340 | Verify Jenkins master logs: 341 | 342 | ```bash 343 | kubectl logs -f jenkins-master-example 344 | ``` 345 | 346 | Verify jenkins-operator logs: 347 | 348 | ```bash 349 | kubectl logs deployment/jenkins-operator 350 | ``` 351 | 352 | ## Troubleshooting 353 | 354 | Delete Jenkins master pod and wait for the new one to come up: 355 | 356 | ```bash 357 | kubectl delete pod jenkins-operator-example 358 | ``` 359 | 360 | [job-dsl]:https://github.com/jenkinsci/job-dsl-plugin 361 | [ssh-credentials]:https://github.com/jenkinsci/ssh-credentials-plugin -------------------------------------------------------------------------------- /docs/how-it-works.md: -------------------------------------------------------------------------------- 1 | # How it works 2 | 3 | This document describes a high level overview how **jenkins-operator** works. 4 | 5 | 1. [Architecture and design](#architecture-and-design) 6 | 2. [Operator State](#operator-state) 7 | 3. [System Jenkins Jobs](#system-jenkins-jobs) 8 | 3. [Jenkins Docker Images](#jenkins-docker-images) 9 | 10 | ## Architecture and design 11 | 12 | The **jenkins-operator** design incorporates the following concepts: 13 | - watches any changes of manifests and maintain the desired state according to deployed custom resource manifest 14 | - implements the main reconciliation loop which consists of two smaller reconciliation loops - base and user 15 | 16 | ![reconcile](../assets/reconcile.png) 17 | 18 | **Base** reconciliation loop takes care of reconciling base Jenkins configuration, which consists of: 19 | - Ensure Manifests - monitors any changes in manifests 20 | - Ensure Jenkins Pod - creates and verifies status of Jenkins master Pod 21 | - Ensure Jenkins Configuration - configures Jenkins instance including hardening, initial configuration for plugins, etc. 22 | - Ensure Jenkins API token - generates Jenkins API token and initialized Jenkins client 23 | 24 | **User** reconciliation loop takes care of reconciling user provided configuration, which consists of: 25 | - Ensure Restore Job - creates Restore job and ensures that restore has been successfully performed 26 | - Ensure Seed Jobs - creates Seed Jobs and ensures that all of them have been successfully executed 27 | - Ensure User Configuration - executed user provided configuration, like groovy scripts, configuration as code or plugins 28 | - Ensure Backup Job - creates Backup job and ensures that backup has been successfully performed 29 | 30 | ![reconcile](../assets/phases.png) 31 | 32 | ## Operator State 33 | 34 | Operator state is kept in custom resource status section, which is used for storing any configuration events or job statuses managed by the operator. 35 | It helps to maintain or recover desired state even after operator or Jenkins restarts. 36 | 37 | ## System Jenkins Jobs 38 | 39 | The operator or Jenkins instance can be restarted at any time and any operation should not block the reconciliation loop. 40 | Taking this into account we implemented custom jobs API for executing system jobs (seed jobs, groovy scripts, etc.) according to the operator lifecycle. 41 | 42 | Main assumptions are: 43 | - do not block reconciliation loop 44 | - fire job, requeue reconciliation loop and verify job status next time 45 | - handle retries if case of failure 46 | - handle build expiration (deadline) 47 | - keep state in the custom resource status section 48 | 49 | ## Jenkins Docker Images 50 | 51 | **jenkins-operator** is fully compatible with **jenkins:lts** docker image and does not introduce any hidden changes there. 52 | If needed, the docker image can easily be changed in custom resource manifest as long as it supports standard Jenkins file system structure. 53 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | This document describes installation procedure for **jenkins-operator**. 4 | All container images can be found at [virtuslab/jenkins-operator](https://hub.docker.com/r/virtuslab/jenkins-operator) 5 | 6 | ## Requirements 7 | 8 | To run **jenkins-operator**, you will need: 9 | - running Kubernetes cluster 10 | - kubectl 11 | 12 | ## Configure Custom Resource Definition 13 | 14 | Install Jenkins Custom Resource Definition: 15 | 16 | ```bash 17 | kubectl apply -f deploy/crds/virtuslab_v1alpha1_jenkins_crd.yaml 18 | ``` 19 | 20 | ## Deploy jenkins-operator 21 | 22 | A`pply Service Account and RBAC roles: 23 | 24 | ```bash 25 | kubectl apply -f deploy/service_account.yaml 26 | kubectl apply -f deploy/role.yaml 27 | kubectl apply -f deploy/role_binding.yaml 28 | ``` 29 | 30 | Update container image to **virtuslab/jenkins-operator:** in `deploy/operator.yaml` and deploy **jenkins-operator**: 31 | 32 | ```bash 33 | kubectl apply -f deploy/operator.yaml 34 | ``` 35 | 36 | Watch **jenkins-operator** instance being created: 37 | 38 | ```bash 39 | kubectl get pods -w 40 | ``` 41 | 42 | Now **jenkins-operator** should be up and running in `default` namespace. 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /docs/security.md: -------------------------------------------------------------------------------- 1 | # Jenkins Security 2 | 3 | By default **jenkins-operator** performs an initial security hardening of Jenkins instance via groovy scripts to prevent any security gaps. 4 | 5 | ## Jenkins Access Control 6 | 7 | Currently **jenkins-operator** generates a username and random password and stores them in a Kubernetes Secret. 8 | However any other authorization mechanisms are possible and can be done via groovy scripts or configuration as code plugin. 9 | For more information take a look at [getting-started#jenkins-customization](getting-started.md#jenkins-customisation). 10 | 11 | ## Jenkins Hardening 12 | 13 | The list below describes all the default security setting configured by the **jenkins-operator**: 14 | - basic settings - use `Mode.EXCLUSIVE` - Jobs must specify that they want to run on master node 15 | - enable CSRF - Cross Site Request Forgery Protection is enabled 16 | - disable usage stats - Jenkins usage stats submitting is disabled 17 | - enable master access control - Slave To Master Access Control is enabled 18 | - disable old JNLP protocols - `JNLP3-connect`, `JNLP2-connect` and `JNLP-connect` are disabled 19 | - disable CLI - CLI access of `/cli` URL is disabled 20 | - configure kubernetes-plugin - secure configuration for Kubernetes plugin 21 | 22 | If you would like to dig a little bit into the code, take a look [here](../pkg/controller/jenkins/configuration/base/resources/base_configuration_configmap.go). 23 | 24 | ## Jenkins API 25 | 26 | The **jenkins-operator** generates and configures Basic Authentication token for Jenkins go client and stores it in a Kubernetes Secret. 27 | 28 | ## Kubernetes 29 | 30 | Kubernetes API permissions are limited by the following roles: 31 | - [jenkins-operator role](../deploy/role.yaml) 32 | - [Jenkins Master role](../pkg/controller/jenkins/configuration/base/resources/rbac.go) 33 | 34 | ## Report a Security Vulnerability 35 | 36 | If you find a vulnerability or any misconfiguration in Jenkins, please report it in the [issues](https://github.com/VirtusLab/jenkins-operator/issues). 37 | 38 | 39 | -------------------------------------------------------------------------------- /pkg/apis/addtoscheme_virtuslab_v1alpha1.go: -------------------------------------------------------------------------------- 1 | package apis 2 | 3 | import ( 4 | "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 5 | ) 6 | 7 | func init() { 8 | // Register the types with the Scheme so the components can map objects to GroupVersionKinds and back 9 | AddToSchemes = append(AddToSchemes, v1alpha1.SchemeBuilder.AddToScheme) 10 | } 11 | -------------------------------------------------------------------------------- /pkg/apis/apis.go: -------------------------------------------------------------------------------- 1 | package apis 2 | 3 | import ( 4 | "k8s.io/apimachinery/pkg/runtime" 5 | ) 6 | 7 | // AddToSchemes may be used to add all resources defined in the project to a Scheme 8 | var AddToSchemes runtime.SchemeBuilder 9 | 10 | // AddToScheme adds all Resources to the Scheme 11 | func AddToScheme(s *runtime.Scheme) error { 12 | return AddToSchemes.AddToScheme(s) 13 | } 14 | -------------------------------------------------------------------------------- /pkg/apis/virtuslab/v1alpha1/doc.go: -------------------------------------------------------------------------------- 1 | // Package v1alpha1 contains API Schema definitions for the virtuslab v1alpha1 API group 2 | // +k8s:deepcopy-gen=package,register 3 | // +groupName=virtuslab.com 4 | package v1alpha1 5 | -------------------------------------------------------------------------------- /pkg/apis/virtuslab/v1alpha1/jenkins_types.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | corev1 "k8s.io/api/core/v1" 5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 6 | ) 7 | 8 | // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! 9 | // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. 10 | 11 | // JenkinsSpec defines the desired state of Jenkins 12 | type JenkinsSpec struct { 13 | // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster 14 | // Important: Run "operator-sdk generate k8s" to regenerate code after modifying this file 15 | Backup JenkinsBackup `json:"backup,omitempty"` 16 | BackupAmazonS3 JenkinsBackupAmazonS3 `json:"backupAmazonS3,omitempty"` 17 | Master JenkinsMaster `json:"master,omitempty"` 18 | SeedJobs []SeedJob `json:"seedJobs,omitempty"` 19 | } 20 | 21 | // JenkinsBackup defines type of Jenkins backup 22 | type JenkinsBackup string 23 | 24 | const ( 25 | // JenkinsBackupTypeNoBackup tells that Jenkins won't backup jobs 26 | JenkinsBackupTypeNoBackup = "NoBackup" 27 | // JenkinsBackupTypeAmazonS3 tells that Jenkins will backup jobs into AWS S3 bucket 28 | JenkinsBackupTypeAmazonS3 = "AmazonS3" 29 | ) 30 | 31 | // AllowedJenkinsBackups consists allowed Jenkins backup types 32 | var AllowedJenkinsBackups = []JenkinsBackup{JenkinsBackupTypeNoBackup, JenkinsBackupTypeAmazonS3} 33 | 34 | // JenkinsBackupAmazonS3 defines backup configuration to AWS S3 bucket 35 | type JenkinsBackupAmazonS3 struct { 36 | BucketName string `json:"bucketName,omitempty"` 37 | BucketPath string `json:"bucketPath,omitempty"` 38 | Region string `json:"region,omitempty"` 39 | } 40 | 41 | // JenkinsMaster defines the Jenkins master pod attributes and plugins, 42 | // every single change requires Jenkins master pod restart 43 | type JenkinsMaster struct { 44 | Image string `json:"image,omitempty"` 45 | Annotations map[string]string `json:"masterAnnotations,omitempty"` 46 | Resources corev1.ResourceRequirements `json:"resources,omitempty"` 47 | Plugins map[string][]string `json:"plugins,omitempty"` 48 | } 49 | 50 | // JenkinsStatus defines the observed state of Jenkins 51 | type JenkinsStatus struct { 52 | // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster 53 | // Important: Run "operator-sdk generate k8s" to regenerate code after modifying this file 54 | BackupRestored bool `json:"backupRestored,omitempty"` 55 | BaseConfigurationCompletedTime *metav1.Time `json:"baseConfigurationCompletedTime,omitempty"` 56 | UserConfigurationCompletedTime *metav1.Time `json:"userConfigurationCompletedTime,omitempty"` 57 | Builds []Build `json:"builds,omitempty"` 58 | } 59 | 60 | // BuildStatus defines type of Jenkins build job status 61 | type BuildStatus string 62 | 63 | const ( 64 | // BuildSuccessStatus - the build had no errors 65 | BuildSuccessStatus BuildStatus = "success" 66 | // BuildUnstableStatus - the build had some errors but they were not fatal. For example, some tests failed 67 | BuildUnstableStatus BuildStatus = "unstable" 68 | // BuildNotBuildStatus - this status code is used in a multi-stage build (like maven2) where a problem in earlier stage prevented later stages from building 69 | BuildNotBuildStatus BuildStatus = "not_build" 70 | // BuildFailureStatus - the build had a fatal error 71 | BuildFailureStatus BuildStatus = "failure" 72 | // BuildAbortedStatus - the build was manually aborted 73 | BuildAbortedStatus BuildStatus = "aborted" 74 | // BuildRunningStatus - this is custom build status for running build, not present in jenkins build result 75 | BuildRunningStatus BuildStatus = "running" 76 | // BuildExpiredStatus - this is custom build status for expired build, not present in jenkins build result 77 | BuildExpiredStatus BuildStatus = "expired" 78 | ) 79 | 80 | // Build defines Jenkins Build status with corresponding metadata 81 | type Build struct { 82 | JobName string `json:"jobName,omitempty"` 83 | Hash string `json:"hash,omitempty"` 84 | Number int64 `json:"number,omitempty"` 85 | Status BuildStatus `json:"status,omitempty"` 86 | Retires int `json:"retries,omitempty"` 87 | CreateTime *metav1.Time `json:"createTime,omitempty"` 88 | LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` 89 | } 90 | 91 | // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object 92 | 93 | // Jenkins is the Schema for the jenkins API 94 | // +k8s:openapi-gen=true 95 | type Jenkins struct { 96 | metav1.TypeMeta `json:",inline"` 97 | metav1.ObjectMeta `json:"metadata,omitempty"` 98 | 99 | Spec JenkinsSpec `json:"spec,omitempty"` 100 | Status JenkinsStatus `json:"status,omitempty"` 101 | } 102 | 103 | // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object 104 | 105 | // JenkinsList contains a list of Jenkins 106 | type JenkinsList struct { 107 | metav1.TypeMeta `json:",inline"` 108 | metav1.ListMeta `json:"metadata,omitempty"` 109 | Items []Jenkins `json:"items"` 110 | } 111 | 112 | // SeedJob defined configuration for seed jobs and deploy keys 113 | type SeedJob struct { 114 | ID string `json:"id"` 115 | Description string `json:"description,omitempty"` 116 | Targets string `json:"targets,omitempty"` 117 | RepositoryBranch string `json:"repositoryBranch,omitempty"` 118 | RepositoryURL string `json:"repositoryUrl"` 119 | PrivateKey PrivateKey `json:"privateKey,omitempty"` 120 | } 121 | 122 | // PrivateKey contains a private key 123 | type PrivateKey struct { 124 | SecretKeyRef *corev1.SecretKeySelector `json:"secretKeyRef"` 125 | } 126 | 127 | func init() { 128 | SchemeBuilder.Register(&Jenkins{}, &JenkinsList{}) 129 | } 130 | -------------------------------------------------------------------------------- /pkg/apis/virtuslab/v1alpha1/register.go: -------------------------------------------------------------------------------- 1 | // NOTE: Boilerplate only. Ignore this file. 2 | 3 | // Package v1alpha1 contains API Schema definitions for the virtuslab v1alpha1 API group 4 | // +k8s:deepcopy-gen=package,register 5 | // +groupName=virtuslab.com 6 | package v1alpha1 7 | 8 | import ( 9 | "k8s.io/apimachinery/pkg/runtime/schema" 10 | "sigs.k8s.io/controller-runtime/pkg/runtime/scheme" 11 | ) 12 | 13 | const ( 14 | // Kind defines Jenkins CRD kind name 15 | Kind = "Jenkins" 16 | ) 17 | 18 | var ( 19 | // SchemeGroupVersion is group version used to register these objects 20 | SchemeGroupVersion = schema.GroupVersion{Group: "virtuslab.com", Version: "v1alpha1"} 21 | 22 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme 23 | SchemeBuilder = &scheme.Builder{GroupVersion: SchemeGroupVersion} 24 | ) 25 | -------------------------------------------------------------------------------- /pkg/apis/virtuslab/v1alpha1/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | // +build !ignore_autogenerated 2 | 3 | /* 4 | Copyright The Kubernetes Authors. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | // Code generated by deepcopy-gen. DO NOT EDIT. 20 | 21 | package v1alpha1 22 | 23 | import ( 24 | v1 "k8s.io/api/core/v1" 25 | runtime "k8s.io/apimachinery/pkg/runtime" 26 | ) 27 | 28 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 29 | func (in *Build) DeepCopyInto(out *Build) { 30 | *out = *in 31 | if in.CreateTime != nil { 32 | in, out := &in.CreateTime, &out.CreateTime 33 | *out = (*in).DeepCopy() 34 | } 35 | if in.LastUpdateTime != nil { 36 | in, out := &in.LastUpdateTime, &out.LastUpdateTime 37 | *out = (*in).DeepCopy() 38 | } 39 | return 40 | } 41 | 42 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Build. 43 | func (in *Build) DeepCopy() *Build { 44 | if in == nil { 45 | return nil 46 | } 47 | out := new(Build) 48 | in.DeepCopyInto(out) 49 | return out 50 | } 51 | 52 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 53 | func (in *Jenkins) DeepCopyInto(out *Jenkins) { 54 | *out = *in 55 | out.TypeMeta = in.TypeMeta 56 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 57 | in.Spec.DeepCopyInto(&out.Spec) 58 | in.Status.DeepCopyInto(&out.Status) 59 | return 60 | } 61 | 62 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Jenkins. 63 | func (in *Jenkins) DeepCopy() *Jenkins { 64 | if in == nil { 65 | return nil 66 | } 67 | out := new(Jenkins) 68 | in.DeepCopyInto(out) 69 | return out 70 | } 71 | 72 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 73 | func (in *Jenkins) DeepCopyObject() runtime.Object { 74 | if c := in.DeepCopy(); c != nil { 75 | return c 76 | } 77 | return nil 78 | } 79 | 80 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 81 | func (in *JenkinsBackupAmazonS3) DeepCopyInto(out *JenkinsBackupAmazonS3) { 82 | *out = *in 83 | return 84 | } 85 | 86 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JenkinsBackupAmazonS3. 87 | func (in *JenkinsBackupAmazonS3) DeepCopy() *JenkinsBackupAmazonS3 { 88 | if in == nil { 89 | return nil 90 | } 91 | out := new(JenkinsBackupAmazonS3) 92 | in.DeepCopyInto(out) 93 | return out 94 | } 95 | 96 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 97 | func (in *JenkinsList) DeepCopyInto(out *JenkinsList) { 98 | *out = *in 99 | out.TypeMeta = in.TypeMeta 100 | out.ListMeta = in.ListMeta 101 | if in.Items != nil { 102 | in, out := &in.Items, &out.Items 103 | *out = make([]Jenkins, len(*in)) 104 | for i := range *in { 105 | (*in)[i].DeepCopyInto(&(*out)[i]) 106 | } 107 | } 108 | return 109 | } 110 | 111 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JenkinsList. 112 | func (in *JenkinsList) DeepCopy() *JenkinsList { 113 | if in == nil { 114 | return nil 115 | } 116 | out := new(JenkinsList) 117 | in.DeepCopyInto(out) 118 | return out 119 | } 120 | 121 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 122 | func (in *JenkinsList) DeepCopyObject() runtime.Object { 123 | if c := in.DeepCopy(); c != nil { 124 | return c 125 | } 126 | return nil 127 | } 128 | 129 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 130 | func (in *JenkinsMaster) DeepCopyInto(out *JenkinsMaster) { 131 | *out = *in 132 | if in.Annotations != nil { 133 | in, out := &in.Annotations, &out.Annotations 134 | *out = make(map[string]string, len(*in)) 135 | for key, val := range *in { 136 | (*out)[key] = val 137 | } 138 | } 139 | in.Resources.DeepCopyInto(&out.Resources) 140 | if in.Plugins != nil { 141 | in, out := &in.Plugins, &out.Plugins 142 | *out = make(map[string][]string, len(*in)) 143 | for key, val := range *in { 144 | var outVal []string 145 | if val == nil { 146 | (*out)[key] = nil 147 | } else { 148 | in, out := &val, &outVal 149 | *out = make([]string, len(*in)) 150 | copy(*out, *in) 151 | } 152 | (*out)[key] = outVal 153 | } 154 | } 155 | return 156 | } 157 | 158 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JenkinsMaster. 159 | func (in *JenkinsMaster) DeepCopy() *JenkinsMaster { 160 | if in == nil { 161 | return nil 162 | } 163 | out := new(JenkinsMaster) 164 | in.DeepCopyInto(out) 165 | return out 166 | } 167 | 168 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 169 | func (in *JenkinsSpec) DeepCopyInto(out *JenkinsSpec) { 170 | *out = *in 171 | out.BackupAmazonS3 = in.BackupAmazonS3 172 | in.Master.DeepCopyInto(&out.Master) 173 | if in.SeedJobs != nil { 174 | in, out := &in.SeedJobs, &out.SeedJobs 175 | *out = make([]SeedJob, len(*in)) 176 | for i := range *in { 177 | (*in)[i].DeepCopyInto(&(*out)[i]) 178 | } 179 | } 180 | return 181 | } 182 | 183 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JenkinsSpec. 184 | func (in *JenkinsSpec) DeepCopy() *JenkinsSpec { 185 | if in == nil { 186 | return nil 187 | } 188 | out := new(JenkinsSpec) 189 | in.DeepCopyInto(out) 190 | return out 191 | } 192 | 193 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 194 | func (in *JenkinsStatus) DeepCopyInto(out *JenkinsStatus) { 195 | *out = *in 196 | if in.BaseConfigurationCompletedTime != nil { 197 | in, out := &in.BaseConfigurationCompletedTime, &out.BaseConfigurationCompletedTime 198 | *out = (*in).DeepCopy() 199 | } 200 | if in.UserConfigurationCompletedTime != nil { 201 | in, out := &in.UserConfigurationCompletedTime, &out.UserConfigurationCompletedTime 202 | *out = (*in).DeepCopy() 203 | } 204 | if in.Builds != nil { 205 | in, out := &in.Builds, &out.Builds 206 | *out = make([]Build, len(*in)) 207 | for i := range *in { 208 | (*in)[i].DeepCopyInto(&(*out)[i]) 209 | } 210 | } 211 | return 212 | } 213 | 214 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JenkinsStatus. 215 | func (in *JenkinsStatus) DeepCopy() *JenkinsStatus { 216 | if in == nil { 217 | return nil 218 | } 219 | out := new(JenkinsStatus) 220 | in.DeepCopyInto(out) 221 | return out 222 | } 223 | 224 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 225 | func (in *PrivateKey) DeepCopyInto(out *PrivateKey) { 226 | *out = *in 227 | if in.SecretKeyRef != nil { 228 | in, out := &in.SecretKeyRef, &out.SecretKeyRef 229 | *out = new(v1.SecretKeySelector) 230 | (*in).DeepCopyInto(*out) 231 | } 232 | return 233 | } 234 | 235 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PrivateKey. 236 | func (in *PrivateKey) DeepCopy() *PrivateKey { 237 | if in == nil { 238 | return nil 239 | } 240 | out := new(PrivateKey) 241 | in.DeepCopyInto(out) 242 | return out 243 | } 244 | 245 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 246 | func (in *SeedJob) DeepCopyInto(out *SeedJob) { 247 | *out = *in 248 | in.PrivateKey.DeepCopyInto(&out.PrivateKey) 249 | return 250 | } 251 | 252 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SeedJob. 253 | func (in *SeedJob) DeepCopy() *SeedJob { 254 | if in == nil { 255 | return nil 256 | } 257 | out := new(SeedJob) 258 | in.DeepCopyInto(out) 259 | return out 260 | } 261 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/backup/aws/s3.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 8 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/configuration/base/resources" 9 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/constants" 10 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/plugins" 11 | "github.com/VirtusLab/jenkins-operator/pkg/log" 12 | 13 | "github.com/go-logr/logr" 14 | corev1 "k8s.io/api/core/v1" 15 | "k8s.io/apimachinery/pkg/types" 16 | k8s "sigs.k8s.io/controller-runtime/pkg/client" 17 | ) 18 | 19 | // AmazonS3Backup is a backup strategy where backup is stored in AWS S3 bucket 20 | // credentials required to make calls to AWS API are provided by user in backup credentials Kubernetes secret 21 | type AmazonS3Backup struct{} 22 | 23 | // GetRestoreJobXML returns Jenkins restore backup job config XML 24 | func (b *AmazonS3Backup) GetRestoreJobXML(jenkins virtuslabv1alpha1.Jenkins) (string, error) { 25 | return ` 26 | 27 | 28 | 29 | false 30 | 31 | 32 | 33 | 34 | 35 | 101 | false 102 | 103 | 104 | false 105 | `, nil 106 | } 107 | 108 | // GetBackupJobXML returns Jenkins backup job config XML 109 | func (b *AmazonS3Backup) GetBackupJobXML(jenkins virtuslabv1alpha1.Jenkins) (string, error) { 110 | return ` 111 | 112 | 113 | 114 | false 115 | 116 | 117 | 118 | 119 | 120 | 121 | H/60 * * * * 122 | 123 | 124 | 125 | 126 | 127 | 183 | false 184 | 185 | 186 | false 187 | `, nil 188 | } 189 | 190 | // IsConfigurationValidForBasePhase validates if user provided valid configuration of backup for base phase 191 | func (b *AmazonS3Backup) IsConfigurationValidForBasePhase(jenkins virtuslabv1alpha1.Jenkins, logger logr.Logger) bool { 192 | if len(jenkins.Spec.BackupAmazonS3.BucketName) == 0 { 193 | logger.V(log.VWarn).Info("Bucket name not set in 'spec.backupAmazonS3.bucketName'") 194 | return false 195 | } 196 | 197 | if len(jenkins.Spec.BackupAmazonS3.BucketPath) == 0 { 198 | logger.V(log.VWarn).Info("Bucket path not set in 'spec.backupAmazonS3.bucketPath'") 199 | return false 200 | } 201 | 202 | if len(jenkins.Spec.BackupAmazonS3.Region) == 0 { 203 | logger.V(log.VWarn).Info("Region not set in 'spec.backupAmazonS3.region'") 204 | return false 205 | } 206 | 207 | return true 208 | } 209 | 210 | // IsConfigurationValidForUserPhase validates if user provided valid configuration of backup for user phase 211 | func (b *AmazonS3Backup) IsConfigurationValidForUserPhase(k8sClient k8s.Client, jenkins virtuslabv1alpha1.Jenkins, logger logr.Logger) (bool, error) { 212 | backupSecretName := resources.GetBackupCredentialsSecretName(&jenkins) 213 | backupSecret := &corev1.Secret{} 214 | err := k8sClient.Get(context.TODO(), types.NamespacedName{Namespace: jenkins.Namespace, Name: backupSecretName}, backupSecret) 215 | if err != nil { 216 | return false, err 217 | } 218 | 219 | if len(backupSecret.Data[constants.BackupAmazonS3SecretSecretKey]) == 0 { 220 | logger.V(log.VWarn).Info(fmt.Sprintf("Secret '%s' doesn't contains key: %s", backupSecretName, constants.BackupAmazonS3SecretSecretKey)) 221 | return false, nil 222 | } 223 | 224 | if len(backupSecret.Data[constants.BackupAmazonS3SecretAccessKey]) == 0 { 225 | logger.V(log.VWarn).Info(fmt.Sprintf("Secret '%s' doesn't contains key: %s", backupSecretName, constants.BackupAmazonS3SecretAccessKey)) 226 | return false, nil 227 | } 228 | 229 | return true, nil 230 | } 231 | 232 | // GetRequiredPlugins returns all required Jenkins plugins by this backup strategy 233 | func (b *AmazonS3Backup) GetRequiredPlugins() map[string][]plugins.Plugin { 234 | return map[string][]plugins.Plugin{ 235 | "aws-java-sdk:1.11.457": { 236 | plugins.Must(plugins.New(plugins.ApacheComponentsClientPlugin)), 237 | plugins.Must(plugins.New(plugins.Jackson2ADIPlugin)), 238 | }, 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/backup/aws/s3_test.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 8 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/constants" 9 | 10 | "github.com/stretchr/testify/assert" 11 | corev1 "k8s.io/api/core/v1" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 14 | logf "sigs.k8s.io/controller-runtime/pkg/runtime/log" 15 | ) 16 | 17 | func TestAmazonS3Backup_IsConfigurationValidForBasePhase(t *testing.T) { 18 | tests := []struct { 19 | name string 20 | jenkins virtuslabv1alpha1.Jenkins 21 | want bool 22 | }{ 23 | { 24 | name: "happy", 25 | jenkins: virtuslabv1alpha1.Jenkins{ 26 | Spec: virtuslabv1alpha1.JenkinsSpec{ 27 | BackupAmazonS3: virtuslabv1alpha1.JenkinsBackupAmazonS3{ 28 | BucketName: "some-value", 29 | BucketPath: "some-value", 30 | Region: "some-value", 31 | }, 32 | }, 33 | }, 34 | want: true, 35 | }, 36 | { 37 | name: "fail, no bucket name", 38 | jenkins: virtuslabv1alpha1.Jenkins{ 39 | Spec: virtuslabv1alpha1.JenkinsSpec{ 40 | BackupAmazonS3: virtuslabv1alpha1.JenkinsBackupAmazonS3{ 41 | BucketName: "", 42 | BucketPath: "some-value", 43 | Region: "some-value", 44 | }, 45 | }, 46 | }, 47 | want: false, 48 | }, 49 | { 50 | name: "fail, no bucket path", 51 | jenkins: virtuslabv1alpha1.Jenkins{ 52 | Spec: virtuslabv1alpha1.JenkinsSpec{ 53 | BackupAmazonS3: virtuslabv1alpha1.JenkinsBackupAmazonS3{ 54 | BucketName: "some-value", 55 | BucketPath: "", 56 | Region: "some-value", 57 | }, 58 | }, 59 | }, 60 | want: false, 61 | }, 62 | { 63 | name: "fail, no region", 64 | jenkins: virtuslabv1alpha1.Jenkins{ 65 | Spec: virtuslabv1alpha1.JenkinsSpec{ 66 | BackupAmazonS3: virtuslabv1alpha1.JenkinsBackupAmazonS3{ 67 | BucketName: "some-value", 68 | BucketPath: "some-value", 69 | Region: "", 70 | }, 71 | }, 72 | }, 73 | want: false, 74 | }, 75 | } 76 | for _, tt := range tests { 77 | t.Run(tt.name, func(t *testing.T) { 78 | r := &AmazonS3Backup{} 79 | got := r.IsConfigurationValidForBasePhase(tt.jenkins, logf.ZapLogger(false)) 80 | assert.Equal(t, tt.want, got) 81 | }) 82 | } 83 | } 84 | 85 | func TestAmazonS3Backup_IsConfigurationValidForUserPhase(t *testing.T) { 86 | tests := []struct { 87 | name string 88 | jenkins *virtuslabv1alpha1.Jenkins 89 | secret *corev1.Secret 90 | want bool 91 | wantErr bool 92 | }{ 93 | { 94 | name: "happy", 95 | jenkins: &virtuslabv1alpha1.Jenkins{ 96 | ObjectMeta: metav1.ObjectMeta{Namespace: "namespace-name", Name: "jenkins-cr-name"}, 97 | }, 98 | secret: &corev1.Secret{ 99 | ObjectMeta: metav1.ObjectMeta{Namespace: "namespace-name", Name: "jenkins-operator-backup-credentials-jenkins-cr-name"}, 100 | Data: map[string][]byte{ 101 | constants.BackupAmazonS3SecretSecretKey: []byte("some-value"), 102 | constants.BackupAmazonS3SecretAccessKey: []byte("some-value"), 103 | }, 104 | }, 105 | want: true, 106 | wantErr: false, 107 | }, 108 | { 109 | name: "fail, no secret", 110 | jenkins: &virtuslabv1alpha1.Jenkins{ 111 | ObjectMeta: metav1.ObjectMeta{Namespace: "namespace-name", Name: "jenkins-cr-name"}, 112 | }, 113 | want: false, 114 | wantErr: true, 115 | }, 116 | { 117 | name: "fail, no secret key in secret", 118 | jenkins: &virtuslabv1alpha1.Jenkins{ 119 | ObjectMeta: metav1.ObjectMeta{Namespace: "namespace-name", Name: "jenkins-cr-name"}, 120 | }, 121 | secret: &corev1.Secret{ 122 | ObjectMeta: metav1.ObjectMeta{Namespace: "namespace-name", Name: "jenkins-operator-backup-credentials-jenkins-cr-name"}, 123 | Data: map[string][]byte{ 124 | constants.BackupAmazonS3SecretSecretKey: []byte(""), 125 | constants.BackupAmazonS3SecretAccessKey: []byte("some-value"), 126 | }, 127 | }, 128 | want: false, 129 | wantErr: false, 130 | }, 131 | { 132 | name: "fail, no access key in secret", 133 | jenkins: &virtuslabv1alpha1.Jenkins{ 134 | ObjectMeta: metav1.ObjectMeta{Namespace: "namespace-name", Name: "jenkins-cr-name"}, 135 | }, 136 | secret: &corev1.Secret{ 137 | ObjectMeta: metav1.ObjectMeta{Namespace: "namespace-name", Name: "jenkins-operator-backup-credentials-jenkins-cr-name"}, 138 | Data: map[string][]byte{ 139 | constants.BackupAmazonS3SecretSecretKey: []byte("some-value"), 140 | constants.BackupAmazonS3SecretAccessKey: []byte(""), 141 | }, 142 | }, 143 | want: false, 144 | wantErr: false, 145 | }, 146 | } 147 | for _, tt := range tests { 148 | t.Run(tt.name, func(t *testing.T) { 149 | k8sClient := fake.NewFakeClient() 150 | logger := logf.ZapLogger(false) 151 | b := &AmazonS3Backup{} 152 | if tt.secret != nil { 153 | e := k8sClient.Create(context.TODO(), tt.secret) 154 | assert.NoError(t, e) 155 | } 156 | got, err := b.IsConfigurationValidForUserPhase(k8sClient, *tt.jenkins, logger) 157 | if tt.wantErr { 158 | assert.Error(t, err) 159 | } else { 160 | assert.NoError(t, err) 161 | } 162 | assert.Equal(t, tt.want, got) 163 | }) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/backup/backup.go: -------------------------------------------------------------------------------- 1 | package backup 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 9 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/backup/aws" 10 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/backup/nobackup" 11 | jenkinsclient "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/client" 12 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/configuration/base/resources" 13 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/constants" 14 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/jobs" 15 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/plugins" 16 | 17 | "github.com/go-logr/logr" 18 | "github.com/pkg/errors" 19 | k8s "sigs.k8s.io/controller-runtime/pkg/client" 20 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 21 | ) 22 | 23 | const ( 24 | restoreJobName = constants.OperatorName + "-restore-backup" 25 | ) 26 | 27 | // Provider defines API of backup providers 28 | type Provider interface { 29 | GetRestoreJobXML(jenkins virtuslabv1alpha1.Jenkins) (string, error) 30 | GetBackupJobXML(jenkins virtuslabv1alpha1.Jenkins) (string, error) 31 | IsConfigurationValidForBasePhase(jenkins virtuslabv1alpha1.Jenkins, logger logr.Logger) bool 32 | IsConfigurationValidForUserPhase(k8sClient k8s.Client, jenkins virtuslabv1alpha1.Jenkins, logger logr.Logger) (bool, error) 33 | GetRequiredPlugins() map[string][]plugins.Plugin 34 | } 35 | 36 | // Backup defines backup manager which is responsible of backup of jobs history 37 | type Backup struct { 38 | jenkins *virtuslabv1alpha1.Jenkins 39 | k8sClient k8s.Client 40 | logger logr.Logger 41 | jenkinsClient jenkinsclient.Jenkins 42 | } 43 | 44 | // New returns instance of backup manager 45 | func New(jenkins *virtuslabv1alpha1.Jenkins, k8sClient k8s.Client, logger logr.Logger, jenkinsClient jenkinsclient.Jenkins) *Backup { 46 | return &Backup{jenkins: jenkins, k8sClient: k8sClient, logger: logger, jenkinsClient: jenkinsClient} 47 | } 48 | 49 | // EnsureRestoreJob creates and updates Jenkins job used to restore backup 50 | func (b *Backup) EnsureRestoreJob() error { 51 | if b.jenkins.Status.UserConfigurationCompletedTime == nil { 52 | provider, err := GetBackupProvider(b.jenkins.Spec.Backup) 53 | if err != nil { 54 | return err 55 | } 56 | restoreJobXML, err := provider.GetRestoreJobXML(*b.jenkins) 57 | if err != nil { 58 | return err 59 | } 60 | _, created, err := b.jenkinsClient.CreateOrUpdateJob(restoreJobXML, restoreJobName) 61 | if err != nil { 62 | return err 63 | } 64 | if created { 65 | b.logger.Info(fmt.Sprintf("'%s' job has been created", restoreJobName)) 66 | } 67 | 68 | return nil 69 | } 70 | 71 | return nil 72 | } 73 | 74 | // RestoreBackup restores backup 75 | func (b *Backup) RestoreBackup() (reconcile.Result, error) { 76 | if !b.jenkins.Status.BackupRestored && b.jenkins.Status.UserConfigurationCompletedTime == nil { 77 | jobsClient := jobs.New(b.jenkinsClient, b.k8sClient, b.logger) 78 | 79 | hash := "hash-restore" // it can be hardcoded because restore job can be run only once 80 | done, err := jobsClient.EnsureBuildJob(restoreJobName, hash, map[string]string{}, b.jenkins, true) 81 | if err != nil { 82 | // build failed and can be recovered - retry build and requeue reconciliation loop with timeout 83 | if err == jobs.ErrorBuildFailed { 84 | return reconcile.Result{Requeue: true, RequeueAfter: time.Second * 10}, nil 85 | } 86 | // build failed and cannot be recovered 87 | if err == jobs.ErrorUnrecoverableBuildFailed { 88 | b.logger.Info(fmt.Sprintf("Restore backup can not be performed. Please check backup configuration in CR and credentials in secret '%s'.", resources.GetBackupCredentialsSecretName(b.jenkins))) 89 | b.logger.Info(fmt.Sprintf("You can also check '%s' job logs in Jenkins", constants.BackupJobName)) 90 | return reconcile.Result{}, nil 91 | } 92 | // unexpected error - requeue reconciliation loop 93 | return reconcile.Result{}, err 94 | } 95 | // build not finished yet - requeue reconciliation loop with timeout 96 | if !done { 97 | return reconcile.Result{Requeue: true, RequeueAfter: time.Second * 10}, nil 98 | } 99 | 100 | b.jenkins.Status.BackupRestored = true 101 | err = b.k8sClient.Update(context.TODO(), b.jenkins) 102 | return reconcile.Result{}, err 103 | } 104 | 105 | return reconcile.Result{}, nil 106 | } 107 | 108 | // EnsureBackupJob creates and updates Jenkins job used to backup 109 | func (b *Backup) EnsureBackupJob() error { 110 | provider, err := GetBackupProvider(b.jenkins.Spec.Backup) 111 | if err != nil { 112 | return err 113 | } 114 | backupJobXML, err := provider.GetBackupJobXML(*b.jenkins) 115 | if err != nil { 116 | return err 117 | } 118 | _, created, err := b.jenkinsClient.CreateOrUpdateJob(backupJobXML, constants.BackupJobName) 119 | if err != nil { 120 | return err 121 | } 122 | if created { 123 | b.logger.Info(fmt.Sprintf("'%s' job has been created", constants.BackupJobName)) 124 | } 125 | 126 | return nil 127 | } 128 | 129 | // GetBackupProvider returns backup provider by type 130 | func GetBackupProvider(backupType virtuslabv1alpha1.JenkinsBackup) (Provider, error) { 131 | switch backupType { 132 | case virtuslabv1alpha1.JenkinsBackupTypeNoBackup: 133 | return &nobackup.NoBackup{}, nil 134 | case virtuslabv1alpha1.JenkinsBackupTypeAmazonS3: 135 | return &aws.AmazonS3Backup{}, nil 136 | default: 137 | return nil, errors.Errorf("Invalid BackupManager type '%s'", backupType) 138 | } 139 | } 140 | 141 | // GetPluginsRequiredByAllBackupProviders returns plugins required by all backup providers 142 | func GetPluginsRequiredByAllBackupProviders() map[string][]plugins.Plugin { 143 | allPlugins := map[string][]plugins.Plugin{} 144 | for _, provider := range getAllProviders() { 145 | for key, value := range provider.GetRequiredPlugins() { 146 | allPlugins[key] = value 147 | } 148 | } 149 | 150 | return allPlugins 151 | } 152 | 153 | func getAllProviders() []Provider { 154 | return []Provider{ 155 | &nobackup.NoBackup{}, &aws.AmazonS3Backup{}, 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/backup/nobackup/nobackup.go: -------------------------------------------------------------------------------- 1 | package nobackup 2 | 3 | import ( 4 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 5 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/plugins" 6 | 7 | "github.com/go-logr/logr" 8 | k8s "sigs.k8s.io/controller-runtime/pkg/client" 9 | ) 10 | 11 | // NoBackup is a backup strategy where there is no backup 12 | type NoBackup struct{} 13 | 14 | var emptyJob = ` 15 | 16 | 17 | 18 | false 19 | 20 | 21 | 22 | false 23 | 24 | 25 | false 26 | 27 | ` 28 | 29 | // GetRestoreJobXML returns Jenkins restore backup job config XML 30 | func (b *NoBackup) GetRestoreJobXML(jenkins virtuslabv1alpha1.Jenkins) (string, error) { 31 | return emptyJob, nil 32 | } 33 | 34 | // GetBackupJobXML returns Jenkins backup job config XML 35 | func (b *NoBackup) GetBackupJobXML(jenkins virtuslabv1alpha1.Jenkins) (string, error) { 36 | return emptyJob, nil 37 | } 38 | 39 | // IsConfigurationValidForBasePhase validates if user provided valid configuration of backup for base phase 40 | func (b *NoBackup) IsConfigurationValidForBasePhase(jenkins virtuslabv1alpha1.Jenkins, logger logr.Logger) bool { 41 | return true 42 | } 43 | 44 | // IsConfigurationValidForUserPhase validates if user provided valid configuration of backup for user phase 45 | func (b *NoBackup) IsConfigurationValidForUserPhase(k8sClient k8s.Client, jenkins virtuslabv1alpha1.Jenkins, logger logr.Logger) (bool, error) { 46 | return true, nil 47 | } 48 | 49 | // GetRequiredPlugins returns all required Jenkins plugins by this backup strategy 50 | func (b *NoBackup) GetRequiredPlugins() map[string][]plugins.Plugin { 51 | return map[string][]plugins.Plugin{} 52 | } 53 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/client/doc.go: -------------------------------------------------------------------------------- 1 | // Package client contains client for Jenkins API 2 | package client 3 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/client/jenkins.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "os/exec" 8 | "strings" 9 | 10 | "github.com/bndr/gojenkins" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | var errorNotFound = errors.New("404") 15 | 16 | // Jenkins defines Jenkins API 17 | type Jenkins interface { 18 | GenerateToken(userName, tokenName string) (*UserToken, error) 19 | Info() (*gojenkins.ExecutorResponse, error) 20 | SafeRestart() error 21 | CreateNode(name string, numExecutors int, description string, remoteFS string, label string, options ...interface{}) (*gojenkins.Node, error) 22 | DeleteNode(name string) (bool, error) 23 | CreateFolder(name string, parents ...string) (*gojenkins.Folder, error) 24 | CreateJobInFolder(config string, jobName string, parentIDs ...string) (*gojenkins.Job, error) 25 | CreateJob(config string, options ...interface{}) (*gojenkins.Job, error) 26 | CreateOrUpdateJob(config, jobName string) (*gojenkins.Job, bool, error) 27 | RenameJob(job string, name string) *gojenkins.Job 28 | CopyJob(copyFrom string, newName string) (*gojenkins.Job, error) 29 | DeleteJob(name string) (bool, error) 30 | BuildJob(name string, options ...interface{}) (int64, error) 31 | GetNode(name string) (*gojenkins.Node, error) 32 | GetLabel(name string) (*gojenkins.Label, error) 33 | GetBuild(jobName string, number int64) (*gojenkins.Build, error) 34 | GetJob(id string, parentIDs ...string) (*gojenkins.Job, error) 35 | GetSubJob(parentID string, childID string) (*gojenkins.Job, error) 36 | GetFolder(id string, parents ...string) (*gojenkins.Folder, error) 37 | GetAllNodes() ([]*gojenkins.Node, error) 38 | GetAllBuildIds(job string) ([]gojenkins.JobBuild, error) 39 | GetAllJobNames() ([]gojenkins.InnerJob, error) 40 | GetAllJobs() ([]*gojenkins.Job, error) 41 | GetQueue() (*gojenkins.Queue, error) 42 | GetQueueUrl() string 43 | GetQueueItem(id int64) (*gojenkins.Task, error) 44 | GetArtifactData(id string) (*gojenkins.FingerPrintResponse, error) 45 | GetPlugins(depth int) (*gojenkins.Plugins, error) 46 | UninstallPlugin(name string) error 47 | HasPlugin(name string) (*gojenkins.Plugin, error) 48 | InstallPlugin(name string, version string) error 49 | ValidateFingerPrint(id string) (bool, error) 50 | GetView(name string) (*gojenkins.View, error) 51 | GetAllViews() ([]*gojenkins.View, error) 52 | CreateView(name string, viewType string) (*gojenkins.View, error) 53 | Poll() (int, error) 54 | } 55 | 56 | type jenkins struct { 57 | gojenkins.Jenkins 58 | } 59 | 60 | // CreateOrUpdateJob creates or updates a job from config 61 | func (jenkins *jenkins) CreateOrUpdateJob(config, jobName string) (job *gojenkins.Job, created bool, err error) { 62 | // create or update 63 | job, err = jenkins.GetJob(jobName) 64 | if isNotFoundError(err) { 65 | job, err = jenkins.CreateJob(config, jobName) 66 | created = true 67 | return 68 | } else if err != nil { 69 | return 70 | } 71 | 72 | err = job.UpdateConfig(config) 73 | return 74 | } 75 | 76 | func isNotFoundError(err error) bool { 77 | if err != nil { 78 | return err.Error() == errorNotFound.Error() 79 | } 80 | return false 81 | } 82 | 83 | // BuildJenkinsAPIUrl returns Jenkins API URL 84 | func BuildJenkinsAPIUrl(namespace, serviceName string, portNumber int, local, minikube bool) (string, error) { 85 | // Get Jenkins URL from minikube command 86 | if local && minikube { 87 | cmd := exec.Command("minikube", "service", "--url", "-n", namespace, serviceName) 88 | var out bytes.Buffer 89 | cmd.Stdout = &out 90 | err := cmd.Run() 91 | if err != nil { 92 | return "", err 93 | } 94 | lines := strings.Split(out.String(), "\n") 95 | // First is for http, the second one is for Jenkins slaves communication 96 | // see pkg/controller/jenkins/configuration/base/resources/service.go 97 | url := lines[0] 98 | return url, nil 99 | } 100 | 101 | if local { 102 | // When run locally make port-forward to jenkins pod ('kubectl -n default port-forward jenkins-operator-example 8080') 103 | return fmt.Sprintf("http://localhost:%d", portNumber), nil 104 | } 105 | 106 | // Connect through Kubernetes service, operator has to be run inside cluster 107 | return fmt.Sprintf("http://%s:%d", serviceName, portNumber), nil 108 | } 109 | 110 | // New creates Jenkins API client 111 | func New(url, user, passwordOrToken string) (Jenkins, error) { 112 | if strings.HasSuffix(url, "/") { 113 | url = url[:len(url)-1] 114 | } 115 | 116 | jenkinsClient := &jenkins{} 117 | jenkinsClient.Server = url 118 | jenkinsClient.Requester = &gojenkins.Requester{ 119 | Base: url, 120 | SslVerify: true, 121 | Client: http.DefaultClient, 122 | BasicAuth: &gojenkins.BasicAuth{Username: user, Password: passwordOrToken}, 123 | } 124 | if _, err := jenkinsClient.Init(); err != nil { 125 | return nil, errors.Wrap(err, "couldn't init Jenkins API client") 126 | } 127 | 128 | status, err := jenkinsClient.Poll() 129 | if err != nil { 130 | return nil, errors.Wrap(err, "couldn't poll data from Jenkins API") 131 | } 132 | if status != http.StatusOK { 133 | return nil, errors.Errorf("couldn't poll data from Jenkins API, invalid status code returned: %d", status) 134 | } 135 | 136 | return jenkinsClient, nil 137 | } 138 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/client/token.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | type userTokenResponseData struct { 11 | Name string `json:"tokenName"` 12 | UUID string `json:"tokenUuid"` 13 | Value string `json:"tokenValue"` 14 | } 15 | 16 | type userTokenResponse struct { 17 | Status string `json:"status"` 18 | Data userTokenResponseData `json:"data"` 19 | } 20 | 21 | // UserToken defines user token for Jenkins API communication 22 | type UserToken struct { 23 | raw *userTokenResponse 24 | base string 25 | } 26 | 27 | // GetToken returns user token 28 | func (token *UserToken) GetToken() string { 29 | return token.raw.Data.Value 30 | } 31 | 32 | func (jenkins *jenkins) GenerateToken(userName, tokenName string) (*UserToken, error) { 33 | token := &UserToken{raw: new(userTokenResponse), 34 | base: fmt.Sprintf("/user/%s/descriptorByName/jenkins.security.ApiTokenProperty/generateNewToken", userName)} 35 | endpoint := token.base 36 | data := map[string]string{"newTokenName": tokenName} 37 | r, err := jenkins.Requester.Post(endpoint, nil, token.raw, data) 38 | 39 | if err != nil { 40 | return nil, errors.Wrap(err, "couldn't generate API token") 41 | } 42 | 43 | if r.StatusCode == http.StatusOK { 44 | if token.raw.Status == "ok" { 45 | return token, nil 46 | } 47 | 48 | return nil, errors.New(token.raw.Status) 49 | } 50 | 51 | return nil, errors.Errorf("couldn't generate API token: %d", r.StatusCode) 52 | } 53 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/base/doc.go: -------------------------------------------------------------------------------- 1 | // Package base is responsible for create Jenkins master pod and it's base configuration 2 | package base 3 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/base/reconcile_test.go: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 8 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/plugins" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "k8s.io/client-go/kubernetes/scheme" 12 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 13 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 14 | logf "sigs.k8s.io/controller-runtime/pkg/runtime/log" 15 | ) 16 | 17 | func TestReconcileJenkinsBaseConfiguration_ensurePluginsRequiredByAllBackupProviders(t *testing.T) { 18 | tests := []struct { 19 | name string 20 | jenkins *virtuslabv1alpha1.Jenkins 21 | requiredPlugins map[string][]plugins.Plugin 22 | want reconcile.Result 23 | wantErr bool 24 | }{ 25 | { 26 | name: "happy, no required plugins", 27 | jenkins: &virtuslabv1alpha1.Jenkins{ 28 | Spec: virtuslabv1alpha1.JenkinsSpec{ 29 | Master: virtuslabv1alpha1.JenkinsMaster{ 30 | Plugins: map[string][]string{ 31 | "first-plugin:0.0.1": {"second-plugin:0.0.1"}, 32 | }, 33 | }, 34 | }, 35 | }, 36 | want: reconcile.Result{Requeue: false}, 37 | wantErr: false, 38 | }, 39 | { 40 | name: "happy, required plugins are set", 41 | jenkins: &virtuslabv1alpha1.Jenkins{ 42 | Spec: virtuslabv1alpha1.JenkinsSpec{ 43 | Master: virtuslabv1alpha1.JenkinsMaster{ 44 | Plugins: map[string][]string{ 45 | "first-plugin:0.0.1": {"second-plugin:0.0.1"}, 46 | }, 47 | }, 48 | }, 49 | }, 50 | requiredPlugins: map[string][]plugins.Plugin{ 51 | "first-plugin:0.0.1": {plugins.Must(plugins.New("second-plugin:0.0.1"))}, 52 | }, 53 | want: reconcile.Result{Requeue: false}, 54 | wantErr: false, 55 | }, 56 | { 57 | name: "happy, jenkins CR must be updated", 58 | jenkins: &virtuslabv1alpha1.Jenkins{ 59 | Spec: virtuslabv1alpha1.JenkinsSpec{ 60 | Master: virtuslabv1alpha1.JenkinsMaster{ 61 | Plugins: map[string][]string{ 62 | "first-plugin:0.0.1": {"second-plugin:0.0.1"}, 63 | }, 64 | }, 65 | }, 66 | }, 67 | requiredPlugins: map[string][]plugins.Plugin{ 68 | "first-plugin:0.0.1": {plugins.Must(plugins.New("second-plugin:0.0.1"))}, 69 | "third-plugin:0.0.1": {}, 70 | }, 71 | want: reconcile.Result{Requeue: true}, 72 | wantErr: false, 73 | }, 74 | { 75 | name: "happy, jenkins CR must be updated", 76 | jenkins: &virtuslabv1alpha1.Jenkins{ 77 | Spec: virtuslabv1alpha1.JenkinsSpec{ 78 | Master: virtuslabv1alpha1.JenkinsMaster{ 79 | Plugins: map[string][]string{ 80 | "first-plugin:0.0.1": {"second-plugin:0.0.1"}, 81 | }, 82 | }, 83 | }, 84 | }, 85 | requiredPlugins: map[string][]plugins.Plugin{ 86 | "first-plugin:0.0.1": {plugins.Must(plugins.New("second-plugin:0.0.1"))}, 87 | "third-plugin:0.0.1": {plugins.Must(plugins.New("fourth-plugin:0.0.1"))}, 88 | }, 89 | want: reconcile.Result{Requeue: true}, 90 | wantErr: false, 91 | }, 92 | } 93 | for _, tt := range tests { 94 | t.Run(tt.name, func(t *testing.T) { 95 | err := virtuslabv1alpha1.SchemeBuilder.AddToScheme(scheme.Scheme) 96 | assert.NoError(t, err) 97 | r := &ReconcileJenkinsBaseConfiguration{ 98 | k8sClient: fake.NewFakeClient(), 99 | scheme: nil, 100 | logger: logf.ZapLogger(false), 101 | jenkins: tt.jenkins, 102 | local: false, 103 | minikube: false, 104 | } 105 | err = r.k8sClient.Create(context.TODO(), tt.jenkins) 106 | assert.NoError(t, err) 107 | got, err := r.ensurePluginsRequiredByAllBackupProviders(tt.requiredPlugins) 108 | if tt.wantErr { 109 | assert.Error(t, err) 110 | } else { 111 | assert.NoError(t, err) 112 | } 113 | assert.Equal(t, tt.want, got) 114 | }) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/base/resources.go: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "k8s.io/apimachinery/pkg/api/errors" 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | "k8s.io/apimachinery/pkg/runtime" 10 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 11 | ) 12 | 13 | func (r *ReconcileJenkinsBaseConfiguration) createResource(obj metav1.Object) error { 14 | runtimeObj, ok := obj.(runtime.Object) 15 | if !ok { 16 | return fmt.Errorf("is not a %T a runtime.Object", obj) 17 | } 18 | 19 | // Set Jenkins instance as the owner and controller 20 | if err := controllerutil.SetControllerReference(r.jenkins, obj, r.scheme); err != nil { 21 | return err 22 | } 23 | 24 | return r.k8sClient.Create(context.TODO(), runtimeObj) 25 | } 26 | 27 | func (r *ReconcileJenkinsBaseConfiguration) updateResource(obj metav1.Object) error { 28 | runtimeObj, ok := obj.(runtime.Object) 29 | if !ok { 30 | return fmt.Errorf("is not a %T a runtime.Object", obj) 31 | } 32 | 33 | // set Jenkins instance as the owner and controller, don't check error(can be already set) 34 | _ = controllerutil.SetControllerReference(r.jenkins, obj, r.scheme) 35 | 36 | return r.k8sClient.Update(context.TODO(), runtimeObj) 37 | } 38 | 39 | func (r *ReconcileJenkinsBaseConfiguration) createOrUpdateResource(obj metav1.Object) error { 40 | runtimeObj, ok := obj.(runtime.Object) 41 | if !ok { 42 | return fmt.Errorf("is not a %T a runtime.Object", obj) 43 | } 44 | 45 | // set Jenkins instance as the owner and controller, don't check error(can be already set) 46 | _ = controllerutil.SetControllerReference(r.jenkins, obj, r.scheme) 47 | 48 | err := r.k8sClient.Create(context.TODO(), runtimeObj) 49 | if err != nil && errors.IsAlreadyExists(err) { 50 | return r.updateResource(obj) 51 | } else if err != nil && !errors.IsAlreadyExists(err) { 52 | return err 53 | } 54 | 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/base/resources/backup_credentials_secret.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "fmt" 5 | 6 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 7 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/constants" 8 | 9 | corev1 "k8s.io/api/core/v1" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | ) 12 | 13 | // GetBackupCredentialsSecretName returns name of Kubernetes secret used to store backup credentials 14 | func GetBackupCredentialsSecretName(jenkins *virtuslabv1alpha1.Jenkins) string { 15 | return fmt.Sprintf("%s-backup-credentials-%s", constants.OperatorName, jenkins.Name) 16 | } 17 | 18 | // NewBackupCredentialsSecret builds the Kubernetes secret used to store backup credentials 19 | func NewBackupCredentialsSecret(jenkins *virtuslabv1alpha1.Jenkins) *corev1.Secret { 20 | meta := metav1.ObjectMeta{ 21 | Name: GetBackupCredentialsSecretName(jenkins), 22 | Namespace: jenkins.ObjectMeta.Namespace, 23 | Labels: BuildLabelsForWatchedResources(jenkins), 24 | } 25 | 26 | return &corev1.Secret{ 27 | TypeMeta: buildSecretTypeMeta(), 28 | ObjectMeta: meta, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/base/resources/base_configuration_configmap.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "fmt" 5 | 6 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 7 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/constants" 8 | 9 | corev1 "k8s.io/api/core/v1" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | ) 12 | 13 | const basicSettingsFmt = ` 14 | import jenkins.model.Jenkins 15 | import jenkins.model.JenkinsLocationConfiguration 16 | import hudson.model.Node.Mode 17 | 18 | def jenkins = Jenkins.instance 19 | //Number of jobs that run simultaneously on master, currently only backup and SeedJob. 20 | jenkins.setNumExecutors(%d) 21 | //Jobs must specify that they want to run on master 22 | jenkins.setMode(Mode.EXCLUSIVE) 23 | jenkins.save() 24 | 25 | ` 26 | 27 | const enableCSRF = ` 28 | import hudson.security.csrf.DefaultCrumbIssuer 29 | import jenkins.model.Jenkins 30 | 31 | def jenkins = Jenkins.instance 32 | 33 | if (jenkins.getCrumbIssuer() == null) { 34 | jenkins.setCrumbIssuer(new DefaultCrumbIssuer(true)) 35 | jenkins.save() 36 | println('CSRF Protection enabled.') 37 | } else { 38 | println('CSRF Protection already configured.') 39 | } 40 | ` 41 | 42 | const disableUsageStats = ` 43 | import jenkins.model.Jenkins 44 | 45 | def jenkins = Jenkins.instance 46 | 47 | if (jenkins.isUsageStatisticsCollected()) { 48 | jenkins.setNoUsageStatistics(true) 49 | jenkins.save() 50 | println('Jenkins usage stats submitting disabled.') 51 | } else { 52 | println('Nothing changed. Usage stats are not submitted to the Jenkins project.') 53 | } 54 | ` 55 | 56 | const enableMasterAccessControl = ` 57 | import jenkins.security.s2m.AdminWhitelistRule 58 | import jenkins.model.Jenkins 59 | 60 | // see https://wiki.jenkins-ci.org/display/JENKINS/Slave+To+Master+Access+Control 61 | def jenkins = Jenkins.instance 62 | jenkins.getInjector() 63 | .getInstance(AdminWhitelistRule.class) 64 | .setMasterKillSwitch(false) // for real though, false equals enabled.......... 65 | jenkins.save() 66 | ` 67 | 68 | const disableInsecureFeatures = ` 69 | import jenkins.* 70 | import jenkins.model.* 71 | import hudson.model.* 72 | import jenkins.security.s2m.* 73 | 74 | def jenkins = Jenkins.instance 75 | 76 | println("Disabling insecure Jenkins features...") 77 | 78 | println("Disabling insecure protocols...") 79 | println("Old protocols: [" + jenkins.getAgentProtocols().join(", ") + "]") 80 | HashSet newProtocols = new HashSet<>(jenkins.getAgentProtocols()) 81 | newProtocols.removeAll(Arrays.asList("JNLP3-connect", "JNLP2-connect", "JNLP-connect", "CLI-connect")) 82 | println("New protocols: [" + newProtocols.join(", ") + "]") 83 | jenkins.setAgentProtocols(newProtocols) 84 | 85 | println("Disabling CLI access of /cli URL...") 86 | def remove = { list -> 87 | list.each { item -> 88 | if (item.getClass().name.contains("CLIAction")) { 89 | println("Removing extension ${item.getClass().name}") 90 | list.remove(item) 91 | } 92 | } 93 | } 94 | remove(jenkins.getExtensionList(RootAction.class)) 95 | remove(jenkins.actions) 96 | 97 | println("Disable CLI completely...") 98 | CLI.get().setEnabled(false) 99 | println("CLI disabled") 100 | 101 | jenkins.save() 102 | ` 103 | 104 | const configureKubernetesPluginFmt = ` 105 | import com.cloudbees.plugins.credentials.CredentialsScope 106 | import com.cloudbees.plugins.credentials.SystemCredentialsProvider 107 | import com.cloudbees.plugins.credentials.domains.Domain 108 | import jenkins.model.Jenkins 109 | import org.csanchez.jenkins.plugins.kubernetes.KubernetesCloud 110 | import org.csanchez.jenkins.plugins.kubernetes.ServiceAccountCredential 111 | 112 | def kubernetesCredentialsId = 'kubernetes-namespace-token' 113 | def jenkins = Jenkins.getInstance() 114 | 115 | ServiceAccountCredential serviceAccountCredential = new ServiceAccountCredential( 116 | CredentialsScope.GLOBAL, 117 | kubernetesCredentialsId, 118 | "Kubernetes Namespace Token" 119 | ) 120 | SystemCredentialsProvider.getInstance().getStore().addCredentials(Domain.global(), serviceAccountCredential) 121 | 122 | KubernetesCloud kubernetes = new KubernetesCloud("kubernetes") 123 | kubernetes.setServerUrl("https://kubernetes.default") 124 | kubernetes.setNamespace("%s") 125 | kubernetes.setCredentialsId(kubernetesCredentialsId) 126 | kubernetes.setJenkinsUrl("http://%s:%d") 127 | kubernetes.setRetentionTimeout(15) 128 | jenkins.clouds.add(kubernetes) 129 | 130 | jenkins.save() 131 | ` 132 | 133 | const configureViews = ` 134 | import hudson.model.ListView 135 | import jenkins.model.Jenkins 136 | 137 | def Jenkins jenkins = Jenkins.getInstance() 138 | 139 | def seedViewName = 'seed-jobs' 140 | def nonSeedViewName = 'non-seed-jobs' 141 | def jenkinsViewName = '` + constants.OperatorName + `' 142 | 143 | if (jenkins.getView(seedViewName) == null) { 144 | def seedView = new ListView(seedViewName) 145 | seedView.setIncludeRegex('.*` + constants.SeedJobSuffix + `.*') 146 | jenkins.addView(seedView) 147 | } 148 | 149 | if (jenkins.getView(nonSeedViewName) == null) { 150 | def nonSeedView = new ListView(nonSeedViewName) 151 | nonSeedView.setIncludeRegex('((?!seed)(?!jenkins).)*') 152 | jenkins.addView(nonSeedView) 153 | } 154 | 155 | if (jenkins.getView(jenkinsViewName) == null) { 156 | def jenkinsView = new ListView(jenkinsViewName) 157 | jenkinsView.setIncludeRegex('.*` + constants.OperatorName + `.*') 158 | jenkins.addView(jenkinsView) 159 | } 160 | 161 | jenkins.save() 162 | ` 163 | 164 | // GetBaseConfigurationConfigMapName returns name of Kubernetes config map used to base configuration 165 | func GetBaseConfigurationConfigMapName(jenkins *virtuslabv1alpha1.Jenkins) string { 166 | return fmt.Sprintf("%s-base-configuration-%s", constants.OperatorName, jenkins.ObjectMeta.Name) 167 | } 168 | 169 | // NewBaseConfigurationConfigMap builds Kubernetes config map used to base configuration 170 | func NewBaseConfigurationConfigMap(meta metav1.ObjectMeta, jenkins *virtuslabv1alpha1.Jenkins) (*corev1.ConfigMap, error) { 171 | meta.Name = GetBaseConfigurationConfigMapName(jenkins) 172 | 173 | return &corev1.ConfigMap{ 174 | TypeMeta: buildConfigMapTypeMeta(), 175 | ObjectMeta: meta, 176 | Data: map[string]string{ 177 | "1-basic-settings.groovy": fmt.Sprintf(basicSettingsFmt, constants.DefaultAmountOfExecutors), 178 | "2-enable-csrf.groovy": enableCSRF, 179 | "3-disable-usage-stats.groovy": disableUsageStats, 180 | "4-enable-master-access-control.groovy": enableMasterAccessControl, 181 | "5-disable-insecure-features.groovy": disableInsecureFeatures, 182 | "6-configure-kubernetes-plugin.groovy": fmt.Sprintf(configureKubernetesPluginFmt, 183 | jenkins.ObjectMeta.Namespace, GetResourceName(jenkins), HTTPPortInt), 184 | "7-configure-views.groovy": configureViews, 185 | }, 186 | }, nil 187 | } 188 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/base/resources/doc.go: -------------------------------------------------------------------------------- 1 | // Package resources contains Kubernetes resources required by Jenkins 2 | package resources 3 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/base/resources/init_configuration_configmap.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "fmt" 5 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/constants" 6 | "text/template" 7 | 8 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 9 | 10 | corev1 "k8s.io/api/core/v1" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | ) 13 | 14 | const createOperatorUserFileName = "createOperatorUser.groovy" 15 | 16 | var createOperatorUserGroovyFmtTemplate = template.Must(template.New(createOperatorUserFileName).Parse(` 17 | import hudson.security.* 18 | 19 | def jenkins = jenkins.model.Jenkins.getInstance() 20 | 21 | def hudsonRealm = new HudsonPrivateSecurityRealm(false) 22 | hudsonRealm.createAccount( 23 | new File('{{ .OperatorCredentialsPath }}/{{ .OperatorUserNameFile }}').text, 24 | new File('{{ .OperatorCredentialsPath }}/{{ .OperatorPasswordFile }}').text) 25 | jenkins.setSecurityRealm(hudsonRealm) 26 | 27 | def strategy = new FullControlOnceLoggedInAuthorizationStrategy() 28 | strategy.setAllowAnonymousRead(false) 29 | jenkins.setAuthorizationStrategy(strategy) 30 | jenkins.save() 31 | `)) 32 | 33 | func buildCreateJenkinsOperatorUserGroovyScript() (*string, error) { 34 | data := struct { 35 | OperatorCredentialsPath string 36 | OperatorUserNameFile string 37 | OperatorPasswordFile string 38 | }{ 39 | OperatorCredentialsPath: jenkinsOperatorCredentialsVolumePath, 40 | OperatorUserNameFile: OperatorCredentialsSecretUserNameKey, 41 | OperatorPasswordFile: OperatorCredentialsSecretPasswordKey, 42 | } 43 | 44 | output, err := render(createOperatorUserGroovyFmtTemplate, data) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | return &output, nil 50 | } 51 | 52 | // GetInitConfigurationConfigMapName returns name of Kubernetes config map used to init configuration 53 | func GetInitConfigurationConfigMapName(jenkins *virtuslabv1alpha1.Jenkins) string { 54 | return fmt.Sprintf("%s-init-configuration-%s", constants.OperatorName, jenkins.ObjectMeta.Name) 55 | } 56 | 57 | // NewInitConfigurationConfigMap builds Kubernetes config map used to init configuration 58 | func NewInitConfigurationConfigMap(meta metav1.ObjectMeta, jenkins *virtuslabv1alpha1.Jenkins) (*corev1.ConfigMap, error) { 59 | meta.Name = GetInitConfigurationConfigMapName(jenkins) 60 | 61 | createJenkinsOperatorUserGroovy, err := buildCreateJenkinsOperatorUserGroovyScript() 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | return &corev1.ConfigMap{ 67 | TypeMeta: buildConfigMapTypeMeta(), 68 | ObjectMeta: meta, 69 | Data: map[string]string{ 70 | createOperatorUserFileName: *createJenkinsOperatorUserGroovy, 71 | }, 72 | }, nil 73 | } 74 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/base/resources/meta.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "fmt" 5 | 6 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 7 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/constants" 8 | 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | ) 11 | 12 | // NewResourceObjectMeta builds ObjectMeta for all Kubernetes resources created by operator 13 | func NewResourceObjectMeta(jenkins *virtuslabv1alpha1.Jenkins) metav1.ObjectMeta { 14 | return metav1.ObjectMeta{ 15 | Name: GetResourceName(jenkins), 16 | Namespace: jenkins.ObjectMeta.Namespace, 17 | Labels: BuildResourceLabels(jenkins), 18 | } 19 | } 20 | 21 | // BuildResourceLabels returns labels for all Kubernetes resources created by operator 22 | func BuildResourceLabels(jenkins *virtuslabv1alpha1.Jenkins) map[string]string { 23 | return map[string]string{ 24 | constants.LabelAppKey: constants.LabelAppValue, 25 | constants.LabelJenkinsCRKey: jenkins.Name, 26 | } 27 | } 28 | 29 | // BuildLabelsForWatchedResources returns labels for Kubernetes resources which operator want to watch 30 | // resources with that labels should not be deleted after Jenkins CR deletion, to prevent this situation don't set 31 | // any owner 32 | func BuildLabelsForWatchedResources(jenkins *virtuslabv1alpha1.Jenkins) map[string]string { 33 | return map[string]string{ 34 | constants.LabelAppKey: constants.LabelAppValue, 35 | constants.LabelJenkinsCRKey: jenkins.Name, 36 | constants.LabelWatchKey: constants.LabelWatchValue, 37 | } 38 | } 39 | 40 | // GetResourceName returns name of Kubernetes resource base on Jenkins CR 41 | func GetResourceName(jenkins *virtuslabv1alpha1.Jenkins) string { 42 | return fmt.Sprintf("%s-%s", constants.LabelAppValue, jenkins.ObjectMeta.Name) 43 | } 44 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/base/resources/operator_credentials_secret.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "fmt" 5 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/constants" 6 | 7 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 8 | 9 | corev1 "k8s.io/api/core/v1" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | ) 12 | 13 | const ( 14 | // OperatorUserName defines username for Jenkins API calls 15 | OperatorUserName = "jenkins-operator" 16 | // OperatorCredentialsSecretUserNameKey defines key of username in operator credentials secret 17 | OperatorCredentialsSecretUserNameKey = "user" 18 | // OperatorCredentialsSecretPasswordKey defines key of password in operator credentials secret 19 | OperatorCredentialsSecretPasswordKey = "password" 20 | // OperatorCredentialsSecretTokenKey defines key of token in operator credentials secret 21 | OperatorCredentialsSecretTokenKey = "token" 22 | // OperatorCredentialsSecretTokenCreationKey defines key of token creation time in operator credentials secret 23 | OperatorCredentialsSecretTokenCreationKey = "tokenCreationTime" 24 | ) 25 | 26 | func buildSecretTypeMeta() metav1.TypeMeta { 27 | return metav1.TypeMeta{ 28 | Kind: "Secret", 29 | APIVersion: "v1", 30 | } 31 | } 32 | 33 | // GetOperatorCredentialsSecretName returns name of Kubernetes secret used to store jenkins operator credentials 34 | // to allow calls to Jenkins API 35 | func GetOperatorCredentialsSecretName(jenkins *virtuslabv1alpha1.Jenkins) string { 36 | return fmt.Sprintf("%s-credentials-%s", constants.OperatorName, jenkins.Name) 37 | } 38 | 39 | // NewOperatorCredentialsSecret builds the Kubernetes secret used to store jenkins operator credentials 40 | // to allow calls to Jenkins API 41 | func NewOperatorCredentialsSecret(meta metav1.ObjectMeta, jenkins *virtuslabv1alpha1.Jenkins) *corev1.Secret { 42 | meta.Name = GetOperatorCredentialsSecretName(jenkins) 43 | return &corev1.Secret{ 44 | TypeMeta: buildSecretTypeMeta(), 45 | ObjectMeta: meta, 46 | Data: map[string][]byte{ 47 | OperatorCredentialsSecretUserNameKey: []byte(OperatorUserName), 48 | OperatorCredentialsSecretPasswordKey: []byte(randomString(20)), 49 | }, 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/base/resources/pod.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "fmt" 5 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 6 | 7 | corev1 "k8s.io/api/core/v1" 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | "k8s.io/apimachinery/pkg/util/intstr" 10 | ) 11 | 12 | const ( 13 | jenkinsHomeVolumeName = "home" 14 | jenkinsHomePath = "/var/jenkins/home" 15 | 16 | jenkinsScriptsVolumeName = "scripts" 17 | jenkinsScriptsVolumePath = "/var/jenkins/scripts" 18 | initScriptName = "init.sh" 19 | backupScriptName = "backup.sh" 20 | 21 | jenkinsOperatorCredentialsVolumeName = "operator-credentials" 22 | jenkinsOperatorCredentialsVolumePath = "/var/jenkins/operator-credentials" 23 | 24 | jenkinsInitConfigurationVolumeName = "init-configuration" 25 | jenkinsInitConfigurationVolumePath = "/var/jenkins/init-configuration" 26 | 27 | jenkinsBaseConfigurationVolumeName = "base-configuration" 28 | // JenkinsBaseConfigurationVolumePath is a path where are groovy scripts used to configure Jenkins 29 | // this scripts are provided by jenkins-operator 30 | JenkinsBaseConfigurationVolumePath = "/var/jenkins/base-configuration" 31 | 32 | jenkinsUserConfigurationVolumeName = "user-configuration" 33 | // JenkinsUserConfigurationVolumePath is a path where are groovy scripts used to configure Jenkins 34 | // this scripts are provided by user 35 | JenkinsUserConfigurationVolumePath = "/var/jenkins/user-configuration" 36 | 37 | jenkinsBackupCredentialsVolumeName = "backup-credentials" 38 | // JenkinsBackupCredentialsVolumePath is a path where are credentials used for backup/restore 39 | // credentials are provided by user 40 | JenkinsBackupCredentialsVolumePath = "/var/jenkins/backup-credentials" 41 | 42 | httpPortName = "http" 43 | slavePortName = "slavelistener" 44 | // HTTPPortInt defines Jenkins master HTTP port 45 | HTTPPortInt = 8080 46 | slavePortInt = 50000 47 | httpPortInt32 = int32(8080) 48 | slavePortInt32 = int32(50000) 49 | 50 | jenkinsUserUID = int64(1000) // build in Docker image jenkins user UID 51 | ) 52 | 53 | func buildPodTypeMeta() metav1.TypeMeta { 54 | return metav1.TypeMeta{ 55 | Kind: "Pod", 56 | APIVersion: "v1", 57 | } 58 | } 59 | 60 | // NewJenkinsMasterPod builds Jenkins Master Kubernetes Pod resource 61 | func NewJenkinsMasterPod(objectMeta metav1.ObjectMeta, jenkins *virtuslabv1alpha1.Jenkins) *corev1.Pod { 62 | initialDelaySeconds := int32(30) 63 | timeoutSeconds := int32(5) 64 | failureThreshold := int32(12) 65 | runAsUser := jenkinsUserUID 66 | 67 | objectMeta.Annotations = jenkins.Spec.Master.Annotations 68 | 69 | return &corev1.Pod{ 70 | TypeMeta: buildPodTypeMeta(), 71 | ObjectMeta: objectMeta, 72 | Spec: corev1.PodSpec{ 73 | ServiceAccountName: objectMeta.Name, 74 | RestartPolicy: corev1.RestartPolicyNever, 75 | SecurityContext: &corev1.PodSecurityContext{ 76 | RunAsUser: &runAsUser, 77 | RunAsGroup: &runAsUser, 78 | }, 79 | Containers: []corev1.Container{ 80 | { 81 | Name: "jenkins-master", 82 | Image: jenkins.Spec.Master.Image, 83 | Command: []string{ 84 | "bash", 85 | fmt.Sprintf("%s/%s", jenkinsScriptsVolumePath, initScriptName), 86 | }, 87 | Lifecycle: &corev1.Lifecycle{ 88 | PreStop: &corev1.Handler{ 89 | Exec: &corev1.ExecAction{ 90 | Command: []string{ 91 | "bash", 92 | fmt.Sprintf("%s/%s", jenkinsScriptsVolumePath, backupScriptName), 93 | }, 94 | }, 95 | }, 96 | }, 97 | LivenessProbe: &corev1.Probe{ 98 | Handler: corev1.Handler{ 99 | HTTPGet: &corev1.HTTPGetAction{ 100 | Path: "/login", 101 | Port: intstr.FromString(httpPortName), 102 | Scheme: corev1.URISchemeHTTP, 103 | }, 104 | }, 105 | InitialDelaySeconds: initialDelaySeconds, 106 | TimeoutSeconds: timeoutSeconds, 107 | FailureThreshold: failureThreshold, 108 | }, 109 | ReadinessProbe: &corev1.Probe{ 110 | Handler: corev1.Handler{ 111 | HTTPGet: &corev1.HTTPGetAction{ 112 | Path: "/login", 113 | Port: intstr.FromString(httpPortName), 114 | Scheme: corev1.URISchemeHTTP, 115 | }, 116 | }, 117 | InitialDelaySeconds: initialDelaySeconds, 118 | }, 119 | Ports: []corev1.ContainerPort{ 120 | { 121 | Name: slavePortName, 122 | ContainerPort: slavePortInt32, 123 | }, 124 | { 125 | Name: httpPortName, 126 | ContainerPort: httpPortInt32, 127 | }, 128 | }, 129 | Env: []corev1.EnvVar{ 130 | { 131 | Name: "JENKINS_HOME", 132 | Value: jenkinsHomePath, 133 | }, 134 | { 135 | Name: "JAVA_OPTS", 136 | Value: "-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -XX:MaxRAMFraction=1 -Djenkins.install.runSetupWizard=false -Djava.awt.headless=true", 137 | }, 138 | }, 139 | Resources: jenkins.Spec.Master.Resources, 140 | VolumeMounts: []corev1.VolumeMount{ 141 | { 142 | Name: jenkinsHomeVolumeName, 143 | MountPath: jenkinsHomePath, 144 | ReadOnly: false, 145 | }, 146 | { 147 | Name: jenkinsScriptsVolumeName, 148 | MountPath: jenkinsScriptsVolumePath, 149 | ReadOnly: true, 150 | }, 151 | { 152 | Name: jenkinsInitConfigurationVolumeName, 153 | MountPath: jenkinsInitConfigurationVolumePath, 154 | ReadOnly: true, 155 | }, 156 | { 157 | Name: jenkinsBaseConfigurationVolumeName, 158 | MountPath: JenkinsBaseConfigurationVolumePath, 159 | ReadOnly: true, 160 | }, 161 | { 162 | Name: jenkinsUserConfigurationVolumeName, 163 | MountPath: JenkinsUserConfigurationVolumePath, 164 | ReadOnly: true, 165 | }, 166 | { 167 | Name: jenkinsOperatorCredentialsVolumeName, 168 | MountPath: jenkinsOperatorCredentialsVolumePath, 169 | ReadOnly: true, 170 | }, 171 | { 172 | Name: jenkinsBackupCredentialsVolumeName, 173 | MountPath: JenkinsBackupCredentialsVolumePath, 174 | ReadOnly: true, 175 | }, 176 | }, 177 | }, 178 | }, 179 | Volumes: []corev1.Volume{ 180 | { 181 | Name: jenkinsHomeVolumeName, 182 | VolumeSource: corev1.VolumeSource{ 183 | EmptyDir: &corev1.EmptyDirVolumeSource{}, 184 | }, 185 | }, 186 | { 187 | Name: jenkinsScriptsVolumeName, 188 | VolumeSource: corev1.VolumeSource{ 189 | ConfigMap: &corev1.ConfigMapVolumeSource{ 190 | LocalObjectReference: corev1.LocalObjectReference{ 191 | Name: getScriptsConfigMapName(jenkins), 192 | }, 193 | }, 194 | }, 195 | }, 196 | { 197 | Name: jenkinsInitConfigurationVolumeName, 198 | VolumeSource: corev1.VolumeSource{ 199 | ConfigMap: &corev1.ConfigMapVolumeSource{ 200 | LocalObjectReference: corev1.LocalObjectReference{ 201 | Name: GetInitConfigurationConfigMapName(jenkins), 202 | }, 203 | }, 204 | }, 205 | }, 206 | { 207 | Name: jenkinsBaseConfigurationVolumeName, 208 | VolumeSource: corev1.VolumeSource{ 209 | ConfigMap: &corev1.ConfigMapVolumeSource{ 210 | LocalObjectReference: corev1.LocalObjectReference{ 211 | Name: GetBaseConfigurationConfigMapName(jenkins), 212 | }, 213 | }, 214 | }, 215 | }, 216 | { 217 | Name: jenkinsUserConfigurationVolumeName, 218 | VolumeSource: corev1.VolumeSource{ 219 | ConfigMap: &corev1.ConfigMapVolumeSource{ 220 | LocalObjectReference: corev1.LocalObjectReference{ 221 | Name: GetUserConfigurationConfigMapName(jenkins), 222 | }, 223 | }, 224 | }, 225 | }, 226 | { 227 | Name: jenkinsOperatorCredentialsVolumeName, 228 | VolumeSource: corev1.VolumeSource{ 229 | Secret: &corev1.SecretVolumeSource{ 230 | SecretName: GetOperatorCredentialsSecretName(jenkins), 231 | }, 232 | }, 233 | }, 234 | { 235 | Name: jenkinsBackupCredentialsVolumeName, 236 | VolumeSource: corev1.VolumeSource{ 237 | Secret: &corev1.SecretVolumeSource{ 238 | SecretName: GetBackupCredentialsSecretName(jenkins), 239 | }, 240 | }, 241 | }, 242 | }, 243 | }, 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/base/resources/random.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | var randomCharset = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") 9 | 10 | func randomString(n int) string { 11 | b := make([]rune, n) 12 | for i := range b { 13 | b[i] = randomCharset[rand.Intn(len(randomCharset))] 14 | } 15 | return string(b) 16 | } 17 | 18 | func init() { 19 | rand.Seed(time.Now().UnixNano()) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/base/resources/rbac.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "k8s.io/api/rbac/v1" 5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 6 | ) 7 | 8 | const ( 9 | createVerb = "create" 10 | deleteVerb = "delete" 11 | getVerb = "get" 12 | listVerb = "list" 13 | watchVerb = "watch" 14 | patchVerb = "patch" 15 | updateVerb = "update" 16 | ) 17 | 18 | // NewRole returns rbac role for jenkins master 19 | func NewRole(meta metav1.ObjectMeta) *v1.Role { 20 | return &v1.Role{ 21 | TypeMeta: metav1.TypeMeta{ 22 | Kind: "Role", 23 | APIVersion: "rbac.authorization.k8s.io/v1", 24 | }, 25 | ObjectMeta: meta, 26 | Rules: []v1.PolicyRule{ 27 | { 28 | APIGroups: []string{""}, 29 | Resources: []string{"pods/portforward"}, 30 | Verbs: []string{createVerb}, 31 | }, 32 | { 33 | APIGroups: []string{""}, 34 | Resources: []string{"pods"}, 35 | Verbs: []string{createVerb, deleteVerb, getVerb, listVerb, patchVerb, updateVerb, watchVerb}, 36 | }, 37 | { 38 | APIGroups: []string{""}, 39 | Resources: []string{"pods/exec"}, 40 | Verbs: []string{createVerb, deleteVerb, getVerb, listVerb, patchVerb, updateVerb, watchVerb}, 41 | }, 42 | { 43 | APIGroups: []string{""}, 44 | Resources: []string{"pods/log"}, 45 | Verbs: []string{getVerb, listVerb, watchVerb}, 46 | }, 47 | //TODO get secrets ??? 48 | }, 49 | } 50 | } 51 | 52 | // NewRoleBinding returns rbac role binding for jenkins master 53 | func NewRoleBinding(meta metav1.ObjectMeta) *v1.RoleBinding { 54 | return &v1.RoleBinding{ 55 | TypeMeta: metav1.TypeMeta{ 56 | Kind: "RoleBinding", 57 | APIVersion: "rbac.authorization.k8s.io/v1", 58 | }, 59 | ObjectMeta: meta, 60 | RoleRef: v1.RoleRef{ 61 | APIGroup: "rbac.authorization.k8s.io", 62 | Kind: "Role", 63 | Name: meta.Name, 64 | }, 65 | Subjects: []v1.Subject{ 66 | { 67 | Kind: "ServiceAccount", 68 | Name: meta.Name, 69 | Namespace: meta.Namespace, 70 | }, 71 | }, 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/base/resources/render.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "bytes" 5 | "text/template" 6 | ) 7 | 8 | // render executes a parsed template (go-template) with configuration from data 9 | func render(template *template.Template, data interface{}) (string, error) { 10 | var buffer bytes.Buffer 11 | if err := template.Execute(&buffer, data); err != nil { 12 | return "", err 13 | } 14 | 15 | return buffer.String(), nil 16 | } 17 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/base/resources/service.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | corev1 "k8s.io/api/core/v1" 5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 6 | "k8s.io/apimachinery/pkg/util/intstr" 7 | ) 8 | 9 | func buildServiceTypeMeta() metav1.TypeMeta { 10 | return metav1.TypeMeta{ 11 | Kind: "Service", 12 | APIVersion: "v1", 13 | } 14 | } 15 | 16 | // NewService builds the Kubernetes service resource 17 | func NewService(meta metav1.ObjectMeta, minikube bool) *corev1.Service { 18 | service := &corev1.Service{ 19 | TypeMeta: buildServiceTypeMeta(), 20 | ObjectMeta: meta, 21 | Spec: corev1.ServiceSpec{ 22 | Selector: meta.Labels, 23 | // The first port have to be Jenkins http port because when run with minikube 24 | // command 'minikube service' returns endpoints in the same sequence 25 | Ports: []corev1.ServicePort{ 26 | { 27 | Name: httpPortName, 28 | Port: httpPortInt32, 29 | TargetPort: intstr.FromInt(HTTPPortInt), 30 | }, 31 | { 32 | Name: slavePortName, 33 | Port: slavePortInt32, 34 | TargetPort: intstr.FromInt(slavePortInt), 35 | }, 36 | }, 37 | }, 38 | } 39 | 40 | if minikube { 41 | // When running locally with minikube cluster Jenkins Service have to be exposed via node port 42 | // to allow communication operator -> Jenkins API 43 | service.Spec.Type = corev1.ServiceTypeNodePort 44 | } else { 45 | service.Spec.Type = corev1.ServiceTypeClusterIP 46 | } 47 | 48 | return service 49 | } 50 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/base/resources/service_account.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "k8s.io/api/core/v1" 5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 6 | ) 7 | 8 | // NewServiceAccount return kubernetes service account 9 | func NewServiceAccount(meta metav1.ObjectMeta) *v1.ServiceAccount { 10 | return &v1.ServiceAccount{ 11 | TypeMeta: metav1.TypeMeta{ 12 | Kind: "ServiceAccount", 13 | APIVersion: "v1", 14 | }, 15 | ObjectMeta: meta, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/base/resources/user_configuration_configmap.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "fmt" 5 | 6 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 7 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/constants" 8 | 9 | corev1 "k8s.io/api/core/v1" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | ) 12 | 13 | const configureTheme = ` 14 | import jenkins.* 15 | import jenkins.model.* 16 | import hudson.* 17 | import hudson.model.* 18 | import org.jenkinsci.plugins.simpletheme.ThemeElement 19 | import org.jenkinsci.plugins.simpletheme.CssTextThemeElement 20 | import org.jenkinsci.plugins.simpletheme.CssUrlThemeElement 21 | 22 | Jenkins jenkins = Jenkins.getInstance() 23 | 24 | def decorator = Jenkins.instance.getDescriptorByType(org.codefirst.SimpleThemeDecorator.class) 25 | 26 | List configElements = new ArrayList<>(); 27 | configElements.add(new CssTextThemeElement("DEFAULT")); 28 | configElements.add(new CssUrlThemeElement("https://cdn.rawgit.com/afonsof/jenkins-material-theme/gh-pages/dist/material-light-green.css")); 29 | decorator.setElements(configElements); 30 | decorator.save(); 31 | 32 | jenkins.save() 33 | ` 34 | 35 | // GetUserConfigurationConfigMapName returns name of Kubernetes config map used to user configuration 36 | func GetUserConfigurationConfigMapName(jenkins *virtuslabv1alpha1.Jenkins) string { 37 | return fmt.Sprintf("%s-user-configuration-%s", constants.OperatorName, jenkins.ObjectMeta.Name) 38 | } 39 | 40 | // NewUserConfigurationConfigMap builds Kubernetes config map used to user configuration 41 | func NewUserConfigurationConfigMap(jenkins *virtuslabv1alpha1.Jenkins) *corev1.ConfigMap { 42 | meta := metav1.ObjectMeta{ 43 | Name: GetUserConfigurationConfigMapName(jenkins), 44 | Namespace: jenkins.ObjectMeta.Namespace, 45 | Labels: BuildLabelsForWatchedResources(jenkins), 46 | } 47 | 48 | return &corev1.ConfigMap{ 49 | TypeMeta: buildConfigMapTypeMeta(), 50 | ObjectMeta: meta, 51 | Data: map[string]string{ 52 | "1-configure-theme.groovy": configureTheme, 53 | }, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/base/validate.go: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | 8 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 9 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/backup" 10 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/configuration/base/resources" 11 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/plugins" 12 | "github.com/VirtusLab/jenkins-operator/pkg/log" 13 | 14 | docker "github.com/docker/distribution/reference" 15 | corev1 "k8s.io/api/core/v1" 16 | "k8s.io/apimachinery/pkg/api/errors" 17 | "k8s.io/apimachinery/pkg/types" 18 | ) 19 | 20 | var ( 21 | dockerImageRegexp = regexp.MustCompile(`^` + docker.TagRegexp.String() + `$`) 22 | ) 23 | 24 | // Validate validates Jenkins CR Spec.master section 25 | func (r *ReconcileJenkinsBaseConfiguration) Validate(jenkins *virtuslabv1alpha1.Jenkins) (bool, error) { 26 | if jenkins.Spec.Master.Image == "" { 27 | r.logger.V(log.VWarn).Info("Image not set") 28 | return false, nil 29 | } 30 | 31 | if !dockerImageRegexp.MatchString(jenkins.Spec.Master.Image) && !docker.ReferenceRegexp.MatchString(jenkins.Spec.Master.Image) { 32 | r.logger.V(log.VWarn).Info("Invalid image") 33 | return false, nil 34 | 35 | } 36 | 37 | if !r.validatePlugins(jenkins.Spec.Master.Plugins) { 38 | return false, nil 39 | } 40 | 41 | valid, err := r.verifyBackup() 42 | if !valid || err != nil { 43 | return valid, err 44 | } 45 | 46 | backupProvider, err := backup.GetBackupProvider(r.jenkins.Spec.Backup) 47 | if err != nil { 48 | return false, err 49 | } 50 | 51 | if !backupProvider.IsConfigurationValidForBasePhase(*r.jenkins, r.logger) { 52 | return false, nil 53 | } 54 | 55 | return true, nil 56 | } 57 | 58 | func (r *ReconcileJenkinsBaseConfiguration) validatePlugins(pluginsWithVersions map[string][]string) bool { 59 | valid := true 60 | allPlugins := map[string][]plugins.Plugin{} 61 | 62 | for rootPluginName, dependentPluginNames := range pluginsWithVersions { 63 | if _, err := plugins.New(rootPluginName); err != nil { 64 | r.logger.V(log.VWarn).Info(fmt.Sprintf("Invalid root plugin name '%s'", rootPluginName)) 65 | valid = false 66 | } 67 | 68 | dependentPlugins := []plugins.Plugin{} 69 | for _, pluginName := range dependentPluginNames { 70 | if p, err := plugins.New(pluginName); err != nil { 71 | r.logger.V(log.VWarn).Info(fmt.Sprintf("Invalid dependent plugin name '%s' in root plugin '%s'", pluginName, rootPluginName)) 72 | valid = false 73 | } else { 74 | dependentPlugins = append(dependentPlugins, *p) 75 | } 76 | } 77 | 78 | allPlugins[rootPluginName] = dependentPlugins 79 | } 80 | 81 | if valid { 82 | return plugins.VerifyDependencies(allPlugins) 83 | } 84 | 85 | return valid 86 | } 87 | 88 | func (r *ReconcileJenkinsBaseConfiguration) verifyBackup() (bool, error) { 89 | if r.jenkins.Spec.Backup == "" { 90 | r.logger.V(log.VWarn).Info("Backup strategy not set in 'spec.backup'") 91 | return false, nil 92 | } 93 | 94 | valid := false 95 | for _, backupType := range virtuslabv1alpha1.AllowedJenkinsBackups { 96 | if r.jenkins.Spec.Backup == backupType { 97 | valid = true 98 | } 99 | } 100 | 101 | if !valid { 102 | r.logger.V(log.VWarn).Info(fmt.Sprintf("Invalid backup strategy '%s'", r.jenkins.Spec.Backup)) 103 | r.logger.V(log.VWarn).Info(fmt.Sprintf("Allowed backups '%+v'", virtuslabv1alpha1.AllowedJenkinsBackups)) 104 | return false, nil 105 | } 106 | 107 | if r.jenkins.Spec.Backup == virtuslabv1alpha1.JenkinsBackupTypeNoBackup { 108 | return true, nil 109 | } 110 | 111 | backupSecretName := resources.GetBackupCredentialsSecretName(r.jenkins) 112 | backupSecret := &corev1.Secret{} 113 | err := r.k8sClient.Get(context.TODO(), types.NamespacedName{Namespace: r.jenkins.Namespace, Name: backupSecretName}, backupSecret) 114 | if err != nil && errors.IsNotFound(err) { 115 | r.logger.V(log.VWarn).Info(fmt.Sprintf("Please create secret '%s' in namespace '%s'", backupSecretName, r.jenkins.Namespace)) 116 | return false, nil 117 | } else if err != nil && !errors.IsNotFound(err) { 118 | return false, err 119 | } 120 | 121 | return true, nil 122 | } 123 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/base/validate_test.go: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 9 | 10 | "github.com/stretchr/testify/assert" 11 | corev1 "k8s.io/api/core/v1" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 14 | logf "sigs.k8s.io/controller-runtime/pkg/runtime/log" 15 | ) 16 | 17 | func TestValidatePlugins(t *testing.T) { 18 | data := []struct { 19 | plugins map[string][]string 20 | expectedResult bool 21 | }{ 22 | { 23 | plugins: map[string][]string{ 24 | "valid-plugin-name:1.0": { 25 | "valid-plugin-name:1.0", 26 | }, 27 | }, 28 | expectedResult: true, 29 | }, 30 | { 31 | plugins: map[string][]string{ 32 | "invalid-plugin-name": { 33 | "invalid-plugin-name", 34 | }, 35 | }, 36 | expectedResult: false, 37 | }, 38 | { 39 | plugins: map[string][]string{ 40 | "valid-plugin-name:1.0": { 41 | "valid-plugin-name:1.0", 42 | "valid-plugin-name2:1.0", 43 | }, 44 | }, 45 | expectedResult: true, 46 | }, 47 | { 48 | plugins: map[string][]string{ 49 | "valid-plugin-name:1.0": {}, 50 | }, 51 | expectedResult: true, 52 | }, 53 | } 54 | 55 | baseReconcileLoop := New(nil, nil, logf.ZapLogger(false), 56 | nil, false, false) 57 | 58 | for index, testingData := range data { 59 | t.Run(fmt.Sprintf("Testing %d plugins set", index), func(t *testing.T) { 60 | result := baseReconcileLoop.validatePlugins(testingData.plugins) 61 | assert.Equal(t, testingData.expectedResult, result) 62 | }) 63 | } 64 | } 65 | 66 | func TestReconcileJenkinsBaseConfiguration_verifyBackup(t *testing.T) { 67 | tests := []struct { 68 | name string 69 | jenkins *virtuslabv1alpha1.Jenkins 70 | secret *corev1.Secret 71 | want bool 72 | wantErr bool 73 | }{ 74 | { 75 | name: "happy, no backup", 76 | jenkins: &virtuslabv1alpha1.Jenkins{ 77 | ObjectMeta: metav1.ObjectMeta{Namespace: "namespace-name", Name: "jenkins-cr-name"}, 78 | Spec: virtuslabv1alpha1.JenkinsSpec{ 79 | Backup: virtuslabv1alpha1.JenkinsBackupTypeNoBackup, 80 | }, 81 | }, 82 | want: true, 83 | wantErr: false, 84 | }, 85 | { 86 | name: "happy", 87 | jenkins: &virtuslabv1alpha1.Jenkins{ 88 | ObjectMeta: metav1.ObjectMeta{Namespace: "namespace-name", Name: "jenkins-cr-name"}, 89 | Spec: virtuslabv1alpha1.JenkinsSpec{ 90 | Backup: virtuslabv1alpha1.JenkinsBackupTypeAmazonS3, 91 | }, 92 | }, 93 | secret: &corev1.Secret{ 94 | ObjectMeta: metav1.ObjectMeta{Namespace: "namespace-name", Name: "jenkins-operator-backup-credentials-jenkins-cr-name"}, 95 | }, 96 | want: true, 97 | wantErr: false, 98 | }, 99 | { 100 | name: "fail, no secret", 101 | jenkins: &virtuslabv1alpha1.Jenkins{ 102 | ObjectMeta: metav1.ObjectMeta{Namespace: "namespace-name", Name: "jenkins-cr-name"}, 103 | Spec: virtuslabv1alpha1.JenkinsSpec{ 104 | Backup: virtuslabv1alpha1.JenkinsBackupTypeAmazonS3, 105 | }, 106 | }, 107 | want: false, 108 | wantErr: false, 109 | }, 110 | { 111 | name: "fail, empty backup type", 112 | jenkins: &virtuslabv1alpha1.Jenkins{ 113 | ObjectMeta: metav1.ObjectMeta{Namespace: "namespace-name", Name: "jenkins-cr-name"}, 114 | Spec: virtuslabv1alpha1.JenkinsSpec{ 115 | Backup: "", 116 | }, 117 | }, 118 | secret: &corev1.Secret{ 119 | ObjectMeta: metav1.ObjectMeta{Namespace: "namespace-name", Name: "jenkins-operator-backup-credentials-jenkins-cr-name"}, 120 | }, 121 | want: false, 122 | wantErr: false, 123 | }, 124 | } 125 | for _, tt := range tests { 126 | t.Run(tt.name, func(t *testing.T) { 127 | r := &ReconcileJenkinsBaseConfiguration{ 128 | k8sClient: fake.NewFakeClient(), 129 | scheme: nil, 130 | logger: logf.ZapLogger(false), 131 | jenkins: tt.jenkins, 132 | local: false, 133 | minikube: false, 134 | } 135 | if tt.secret != nil { 136 | e := r.k8sClient.Create(context.TODO(), tt.secret) 137 | assert.NoError(t, e) 138 | } 139 | got, err := r.verifyBackup() 140 | if tt.wantErr { 141 | assert.Error(t, err) 142 | } else { 143 | assert.NoError(t, err) 144 | } 145 | assert.Equal(t, tt.want, got) 146 | }) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/user/doc.go: -------------------------------------------------------------------------------- 1 | // Package user implements Jenkins user configuration and reconciliation 2 | package user 3 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/user/reconcile.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 8 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/backup" 9 | jenkinsclient "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/client" 10 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/configuration/base/resources" 11 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/configuration/user/seedjobs" 12 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/constants" 13 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/groovy" 14 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/jobs" 15 | 16 | "github.com/go-logr/logr" 17 | corev1 "k8s.io/api/core/v1" 18 | "k8s.io/apimachinery/pkg/types" 19 | k8s "sigs.k8s.io/controller-runtime/pkg/client" 20 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 21 | ) 22 | 23 | // ReconcileUserConfiguration defines values required for Jenkins user configuration 24 | type ReconcileUserConfiguration struct { 25 | k8sClient k8s.Client 26 | jenkinsClient jenkinsclient.Jenkins 27 | logger logr.Logger 28 | jenkins *virtuslabv1alpha1.Jenkins 29 | } 30 | 31 | // New create structure which takes care of user configuration 32 | func New(k8sClient k8s.Client, jenkinsClient jenkinsclient.Jenkins, logger logr.Logger, 33 | jenkins *virtuslabv1alpha1.Jenkins) *ReconcileUserConfiguration { 34 | return &ReconcileUserConfiguration{ 35 | k8sClient: k8sClient, 36 | jenkinsClient: jenkinsClient, 37 | logger: logger, 38 | jenkins: jenkins, 39 | } 40 | } 41 | 42 | // Reconcile it's a main reconciliation loop for user supplied configuration 43 | func (r *ReconcileUserConfiguration) Reconcile() (reconcile.Result, error) { 44 | backupManager := backup.New(r.jenkins, r.k8sClient, r.logger, r.jenkinsClient) 45 | if err := backupManager.EnsureRestoreJob(); err != nil { 46 | return reconcile.Result{}, err 47 | } 48 | 49 | result, err := backupManager.RestoreBackup() 50 | if err != nil { 51 | return reconcile.Result{}, err 52 | } 53 | if result.Requeue { 54 | return result, nil 55 | } 56 | 57 | // reconcile seed jobs 58 | result, err = r.ensureSeedJobs() 59 | if err != nil { 60 | return reconcile.Result{}, err 61 | } 62 | if result.Requeue { 63 | return result, nil 64 | } 65 | 66 | result, err = r.ensureUserConfiguration(r.jenkinsClient) 67 | if err != nil { 68 | return reconcile.Result{}, err 69 | } 70 | if result.Requeue { 71 | return result, nil 72 | } 73 | 74 | err = backupManager.EnsureBackupJob() 75 | if err != nil { 76 | return reconcile.Result{}, err 77 | } 78 | 79 | return reconcile.Result{}, nil 80 | } 81 | 82 | func (r *ReconcileUserConfiguration) ensureSeedJobs() (reconcile.Result, error) { 83 | seedJobs := seedjobs.New(r.jenkinsClient, r.k8sClient, r.logger) 84 | done, err := seedJobs.EnsureSeedJobs(r.jenkins) 85 | if err != nil { 86 | // build failed and can be recovered - retry build and requeue reconciliation loop with timeout 87 | if err == jobs.ErrorBuildFailed { 88 | return reconcile.Result{Requeue: true, RequeueAfter: time.Second * 10}, nil 89 | } 90 | // build failed and cannot be recovered 91 | if err == jobs.ErrorUnrecoverableBuildFailed { 92 | return reconcile.Result{}, nil 93 | } 94 | // unexpected error - requeue reconciliation loop 95 | return reconcile.Result{}, err 96 | } 97 | // build not finished yet - requeue reconciliation loop with timeout 98 | if !done { 99 | return reconcile.Result{Requeue: true, RequeueAfter: time.Second * 10}, nil 100 | } 101 | return reconcile.Result{}, nil 102 | } 103 | 104 | func (r *ReconcileUserConfiguration) ensureUserConfiguration(jenkinsClient jenkinsclient.Jenkins) (reconcile.Result, error) { 105 | groovyClient := groovy.New(jenkinsClient, r.k8sClient, r.logger, constants.UserConfigurationJobName, resources.JenkinsUserConfigurationVolumePath) 106 | 107 | err := groovyClient.ConfigureGroovyJob() 108 | if err != nil { 109 | return reconcile.Result{}, err 110 | } 111 | 112 | configuration := &corev1.ConfigMap{} 113 | namespaceName := types.NamespacedName{Namespace: r.jenkins.Namespace, Name: resources.GetUserConfigurationConfigMapName(r.jenkins)} 114 | err = r.k8sClient.Get(context.TODO(), namespaceName, configuration) 115 | if err != nil { 116 | return reconcile.Result{}, err 117 | } 118 | 119 | done, err := groovyClient.EnsureGroovyJob(configuration.Data, r.jenkins) 120 | if err != nil { 121 | return reconcile.Result{}, err 122 | } 123 | 124 | if !done { 125 | return reconcile.Result{Requeue: true, RequeueAfter: time.Second * 10}, nil 126 | } 127 | 128 | return reconcile.Result{}, nil 129 | } 130 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/user/seedjobs/doc.go: -------------------------------------------------------------------------------- 1 | // Package seedjobs implements seed jobs configuration 2 | package seedjobs 3 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/user/seedjobs/seedjobs.go: -------------------------------------------------------------------------------- 1 | package seedjobs 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "encoding/base64" 7 | "fmt" 8 | 9 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 10 | jenkinsclient "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/client" 11 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/constants" 12 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/jobs" 13 | "github.com/VirtusLab/jenkins-operator/pkg/log" 14 | 15 | "github.com/go-logr/logr" 16 | "k8s.io/api/core/v1" 17 | "k8s.io/apimachinery/pkg/types" 18 | k8s "sigs.k8s.io/controller-runtime/pkg/client" 19 | ) 20 | 21 | const ( 22 | // ConfigureSeedJobsName this is the fixed seed job name 23 | ConfigureSeedJobsName = constants.OperatorName + "-configure-seed-job" 24 | 25 | deployKeyIDParameterName = "DEPLOY_KEY_ID" 26 | privateKeyParameterName = "PRIVATE_KEY" 27 | repositoryURLParameterName = "REPOSITORY_URL" 28 | repositoryBranchParameterName = "REPOSITORY_BRANCH" 29 | targetsParameterName = "TARGETS" 30 | displayNameParameterName = "SEED_JOB_DISPLAY_NAME" 31 | ) 32 | 33 | // SeedJobs defines API for configuring and ensuring Jenkins Seed Jobs and Deploy Keys 34 | type SeedJobs struct { 35 | jenkinsClient jenkinsclient.Jenkins 36 | k8sClient k8s.Client 37 | logger logr.Logger 38 | } 39 | 40 | // New creates SeedJobs object 41 | func New(jenkinsClient jenkinsclient.Jenkins, k8sClient k8s.Client, logger logr.Logger) *SeedJobs { 42 | return &SeedJobs{ 43 | jenkinsClient: jenkinsClient, 44 | k8sClient: k8sClient, 45 | logger: logger, 46 | } 47 | } 48 | 49 | // EnsureSeedJobs configures seed job and runs it for every entry from Jenkins.Spec.SeedJobs 50 | func (s *SeedJobs) EnsureSeedJobs(jenkins *virtuslabv1alpha1.Jenkins) (done bool, err error) { 51 | err = s.createJob() 52 | if err != nil { 53 | s.logger.V(log.VWarn).Info("Couldn't create jenkins seed job") 54 | return false, err 55 | } 56 | done, err = s.buildJobs(jenkins) 57 | if err != nil { 58 | s.logger.V(log.VWarn).Info("Couldn't build jenkins seed job") 59 | return false, err 60 | } 61 | return done, nil 62 | } 63 | 64 | // createJob is responsible for creating jenkins job which configures jenkins seed jobs and deploy keys 65 | func (s *SeedJobs) createJob() error { 66 | _, created, err := s.jenkinsClient.CreateOrUpdateJob(seedJobConfigXML, ConfigureSeedJobsName) 67 | if err != nil { 68 | return err 69 | } 70 | if created { 71 | s.logger.Info(fmt.Sprintf("'%s' job has been created", ConfigureSeedJobsName)) 72 | } 73 | return nil 74 | } 75 | 76 | // buildJobs is responsible for running jenkins builds which configures jenkins seed jobs and deploy keys 77 | func (s *SeedJobs) buildJobs(jenkins *virtuslabv1alpha1.Jenkins) (done bool, err error) { 78 | allDone := true 79 | seedJobs := jenkins.Spec.SeedJobs 80 | for _, seedJob := range seedJobs { 81 | privateKey, err := s.privateKeyFromSecret(jenkins.Namespace, seedJob) 82 | if err != nil { 83 | return false, err 84 | } 85 | parameters := map[string]string{ 86 | deployKeyIDParameterName: seedJob.ID, 87 | privateKeyParameterName: privateKey, 88 | repositoryURLParameterName: seedJob.RepositoryURL, 89 | repositoryBranchParameterName: seedJob.RepositoryBranch, 90 | targetsParameterName: seedJob.Targets, 91 | displayNameParameterName: fmt.Sprintf("Seed Job from %s", seedJob.ID), 92 | } 93 | 94 | hash := sha256.New() 95 | hash.Write([]byte(parameters[deployKeyIDParameterName])) 96 | hash.Write([]byte(parameters[privateKeyParameterName])) 97 | hash.Write([]byte(parameters[repositoryURLParameterName])) 98 | hash.Write([]byte(parameters[repositoryBranchParameterName])) 99 | hash.Write([]byte(parameters[targetsParameterName])) 100 | hash.Write([]byte(parameters[displayNameParameterName])) 101 | encodedHash := base64.URLEncoding.EncodeToString(hash.Sum(nil)) 102 | 103 | jobsClient := jobs.New(s.jenkinsClient, s.k8sClient, s.logger) 104 | done, err := jobsClient.EnsureBuildJob(ConfigureSeedJobsName, encodedHash, parameters, jenkins, true) 105 | if err != nil { 106 | return false, err 107 | } 108 | if !done { 109 | allDone = false 110 | } 111 | } 112 | return allDone, nil 113 | } 114 | 115 | // privateKeyFromSecret it's utility function which extracts deploy key from the kubernetes secret 116 | func (s *SeedJobs) privateKeyFromSecret(namespace string, seedJob virtuslabv1alpha1.SeedJob) (string, error) { 117 | if seedJob.PrivateKey.SecretKeyRef != nil { 118 | deployKeySecret := &v1.Secret{} 119 | namespaceName := types.NamespacedName{Namespace: namespace, Name: seedJob.PrivateKey.SecretKeyRef.Name} 120 | err := s.k8sClient.Get(context.TODO(), namespaceName, deployKeySecret) 121 | if err != nil { 122 | return "", err 123 | } 124 | return string(deployKeySecret.Data[seedJob.PrivateKey.SecretKeyRef.Key]), nil 125 | } 126 | return "", nil 127 | } 128 | 129 | // FIXME(antoniaklja) use mask-password plugin for params.PRIVATE_KEY 130 | // seedJobConfigXML this is the XML representation of seed job 131 | var seedJobConfigXML = ` 132 | 133 | 134 | Configure Seed Jobs 135 | false 136 | 137 | 138 | 139 | 140 | ` + deployKeyIDParameterName + ` 141 | 142 | 143 | false 144 | 145 | 146 | ` + privateKeyParameterName + ` 147 | 148 | 149 | 150 | 151 | ` + repositoryURLParameterName + ` 152 | 153 | 154 | false 155 | 156 | 157 | ` + repositoryBranchParameterName + ` 158 | 159 | master 160 | false 161 | 162 | 163 | ` + displayNameParameterName + ` 164 | 165 | 166 | false 167 | 168 | 169 | ` + targetsParameterName + ` 170 | 171 | cicd/jobs/*.jenkins 172 | false 173 | 174 | 175 | 176 | 177 | 178 | 252 | false 253 | 254 | 255 | false 256 | 257 | ` 258 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/user/seedjobs/seedjobs_test.go: -------------------------------------------------------------------------------- 1 | package seedjobs 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 9 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/client" 10 | 11 | "github.com/bndr/gojenkins" 12 | "github.com/golang/mock/gomock" 13 | "github.com/stretchr/testify/assert" 14 | corev1 "k8s.io/api/core/v1" 15 | "k8s.io/apimachinery/pkg/api/resource" 16 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 17 | "k8s.io/apimachinery/pkg/types" 18 | "k8s.io/client-go/kubernetes/scheme" 19 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 20 | logf "sigs.k8s.io/controller-runtime/pkg/runtime/log" 21 | ) 22 | 23 | func TestEnsureSeedJobs(t *testing.T) { 24 | // given 25 | logger := logf.ZapLogger(false) 26 | ctrl := gomock.NewController(t) 27 | ctx := context.TODO() 28 | defer ctrl.Finish() 29 | 30 | jenkinsClient := client.NewMockJenkins(ctrl) 31 | fakeClient := fake.NewFakeClient() 32 | err := virtuslabv1alpha1.SchemeBuilder.AddToScheme(scheme.Scheme) 33 | assert.NoError(t, err) 34 | 35 | jenkins := jenkinsCustomResource() 36 | err = fakeClient.Create(ctx, jenkins) 37 | assert.NoError(t, err) 38 | buildNumber := int64(1) 39 | 40 | for reconcileAttempt := 1; reconcileAttempt <= 2; reconcileAttempt++ { 41 | logger.Info(fmt.Sprintf("Reconcile attempt #%d", reconcileAttempt)) 42 | 43 | seedJobs := New(jenkinsClient, fakeClient, logger) 44 | 45 | // first run - should create job and schedule build 46 | if reconcileAttempt == 1 { 47 | jenkinsClient. 48 | EXPECT(). 49 | CreateOrUpdateJob(seedJobConfigXML, ConfigureSeedJobsName). 50 | Return(nil, true, nil) 51 | 52 | jenkinsClient. 53 | EXPECT(). 54 | GetJob(ConfigureSeedJobsName). 55 | Return(&gojenkins.Job{ 56 | Raw: &gojenkins.JobResponse{ 57 | NextBuildNumber: buildNumber, 58 | }, 59 | }, nil) 60 | 61 | jenkinsClient. 62 | EXPECT(). 63 | BuildJob(ConfigureSeedJobsName, gomock.Any()). 64 | Return(int64(0), nil) 65 | } 66 | 67 | // second run - should update and finish job 68 | if reconcileAttempt == 2 { 69 | jenkinsClient. 70 | EXPECT(). 71 | CreateOrUpdateJob(seedJobConfigXML, ConfigureSeedJobsName). 72 | Return(nil, false, nil) 73 | 74 | jenkinsClient. 75 | EXPECT(). 76 | GetBuild(ConfigureSeedJobsName, gomock.Any()). 77 | Return(&gojenkins.Build{ 78 | Raw: &gojenkins.BuildResponse{ 79 | Result: string(virtuslabv1alpha1.BuildSuccessStatus), 80 | }, 81 | }, nil) 82 | } 83 | 84 | done, err := seedJobs.EnsureSeedJobs(jenkins) 85 | assert.NoError(t, err) 86 | 87 | err = fakeClient.Get(ctx, types.NamespacedName{Name: jenkins.Name, Namespace: jenkins.Namespace}, jenkins) 88 | assert.NoError(t, err) 89 | 90 | assert.Equal(t, 1, len(jenkins.Status.Builds), "There is one running job") 91 | build := jenkins.Status.Builds[0] 92 | assert.Equal(t, buildNumber, build.Number) 93 | assert.Equal(t, ConfigureSeedJobsName, build.JobName) 94 | assert.NotNil(t, build.CreateTime) 95 | assert.NotEmpty(t, build.Hash) 96 | assert.NotNil(t, build.LastUpdateTime) 97 | assert.Equal(t, 0, build.Retires) 98 | 99 | // first run - should create job and schedule build 100 | if reconcileAttempt == 1 { 101 | assert.False(t, done) 102 | assert.Equal(t, string(virtuslabv1alpha1.BuildRunningStatus), string(build.Status)) 103 | } 104 | 105 | // second run - should update and finish job 106 | if reconcileAttempt == 2 { 107 | assert.True(t, done) 108 | assert.Equal(t, string(virtuslabv1alpha1.BuildSuccessStatus), string(build.Status)) 109 | } 110 | 111 | } 112 | } 113 | 114 | func jenkinsCustomResource() *virtuslabv1alpha1.Jenkins { 115 | return &virtuslabv1alpha1.Jenkins{ 116 | ObjectMeta: metav1.ObjectMeta{ 117 | Name: "jenkins", 118 | Namespace: "default", 119 | }, 120 | Spec: virtuslabv1alpha1.JenkinsSpec{ 121 | Master: virtuslabv1alpha1.JenkinsMaster{ 122 | Image: "jenkins/jenkins", 123 | Annotations: map[string]string{"test": "label"}, 124 | Resources: corev1.ResourceRequirements{ 125 | Requests: corev1.ResourceList{ 126 | corev1.ResourceCPU: resource.MustParse("300m"), 127 | corev1.ResourceMemory: resource.MustParse("500Mi"), 128 | }, 129 | Limits: corev1.ResourceList{ 130 | corev1.ResourceCPU: resource.MustParse("2"), 131 | corev1.ResourceMemory: resource.MustParse("2Gi"), 132 | }, 133 | }, 134 | }, 135 | SeedJobs: []virtuslabv1alpha1.SeedJob{ 136 | { 137 | ID: "jenkins-operator-e2e", 138 | Targets: "cicd/jobs/*.jenkins", 139 | Description: "Jenkins Operator e2e tests repository", 140 | RepositoryBranch: "master", 141 | RepositoryURL: "https://github.com/VirtusLab/jenkins-operator-e2e.git", 142 | }, 143 | }, 144 | }, 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/user/validate.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "crypto/x509" 6 | "encoding/pem" 7 | "errors" 8 | "fmt" 9 | "strings" 10 | 11 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 12 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/backup" 13 | "github.com/VirtusLab/jenkins-operator/pkg/log" 14 | 15 | "k8s.io/api/core/v1" 16 | apierrors "k8s.io/apimachinery/pkg/api/errors" 17 | "k8s.io/apimachinery/pkg/types" 18 | ) 19 | 20 | // Validate validates Jenkins CR Spec section 21 | func (r *ReconcileUserConfiguration) Validate(jenkins *virtuslabv1alpha1.Jenkins) (bool, error) { 22 | valid, err := r.validateSeedJobs(jenkins) 23 | if !valid || err != nil { 24 | return valid, err 25 | } 26 | 27 | backupProvider, err := backup.GetBackupProvider(r.jenkins.Spec.Backup) 28 | if err != nil { 29 | return false, err 30 | } 31 | 32 | return backupProvider.IsConfigurationValidForUserPhase(r.k8sClient, *r.jenkins, r.logger) 33 | } 34 | 35 | func (r *ReconcileUserConfiguration) validateSeedJobs(jenkins *virtuslabv1alpha1.Jenkins) (bool, error) { 36 | valid := true 37 | if jenkins.Spec.SeedJobs != nil { 38 | for _, seedJob := range jenkins.Spec.SeedJobs { 39 | logger := r.logger.WithValues("seedJob", fmt.Sprintf("%+v", seedJob)).V(log.VWarn) 40 | 41 | // validate seed job id is not empty 42 | if len(seedJob.ID) == 0 { 43 | logger.Info("seed job id can't be empty") 44 | valid = false 45 | } 46 | 47 | // validate repository url match private key 48 | if strings.Contains(seedJob.RepositoryURL, "git@") { 49 | if seedJob.PrivateKey.SecretKeyRef == nil { 50 | logger.Info("private key can't be empty while using ssh repository url") 51 | valid = false 52 | } 53 | } 54 | 55 | // validate private key from secret 56 | if seedJob.PrivateKey.SecretKeyRef != nil { 57 | deployKeySecret := &v1.Secret{} 58 | namespaceName := types.NamespacedName{Namespace: jenkins.Namespace, Name: seedJob.PrivateKey.SecretKeyRef.Name} 59 | err := r.k8sClient.Get(context.TODO(), namespaceName, deployKeySecret) 60 | if err != nil && apierrors.IsNotFound(err) { 61 | logger.Info("secret not found") 62 | valid = false 63 | } else if err != nil { 64 | return false, err 65 | } 66 | 67 | privateKey := string(deployKeySecret.Data[seedJob.PrivateKey.SecretKeyRef.Key]) 68 | if privateKey == "" { 69 | logger.Info("private key is empty") 70 | valid = false 71 | } 72 | 73 | if err := validatePrivateKey(privateKey); err != nil { 74 | logger.Info(fmt.Sprintf("private key is invalid: %s", err)) 75 | valid = false 76 | } 77 | } 78 | } 79 | } 80 | return valid, nil 81 | } 82 | 83 | func validatePrivateKey(privateKey string) error { 84 | block, _ := pem.Decode([]byte(privateKey)) 85 | if block == nil { 86 | return errors.New("failed to decode PEM block") 87 | } 88 | 89 | priv, err := x509.ParsePKCS1PrivateKey(block.Bytes) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | err = priv.Validate() 95 | if err != nil { 96 | return err 97 | } 98 | 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/configuration/user/validate_test.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 9 | 10 | "github.com/stretchr/testify/assert" 11 | corev1 "k8s.io/api/core/v1" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 14 | logf "sigs.k8s.io/controller-runtime/pkg/runtime/log" 15 | ) 16 | 17 | var fakePrivateKey = `-----BEGIN RSA PRIVATE KEY----- 18 | MIIEpAIBAAKCAQEArK4ld6i2iqW6L3jaTZaKD/v7PjDn+Ik9MXp+kvLcUw/+wEGm 19 | 285UwqLnDDlBhSi9nDgJ+m1XU87VCpz/DXW23R/CQcMX2xunib4wWLQqoR3CWbk3 20 | SwiLd8TWAvXkxdXm8fDOGAZbYK2alMV+M+9E2OpZsBUCxmb/3FAofF6JccKoJOH8 21 | UveRNSOx7IXPKtHFiypBhWM4l6ZjgJKm+DRIEhyvoC+pHzcum2ZEPOv+ZJDy5jXK 22 | ZHcNQXVnAZtCcojcjVUBw2rZms+fQ6Volv2JT71Gpykzx/rChhwNwxdAEwjLjKjL 23 | nBWEh/WxsS3NbM7zb4B2XGMCeWVeb/niUwpy+wIDAQABAoIBAQCjGkJNidARmYQI 24 | /u/DxWNWwb2H+o3BFW/1YixYBIjS9BK96cT/bR5mUZRG2XXnnpmqCsxx/AE2KfDU 25 | e4H1ZrB4oFzN3MaVsMNIuZnUzyhM0l0WfnmZp9KEKCm01ilmLCpdcARacPaylIej 26 | 6f7QcznmYUShqtbaK8OUhyoWfvz3s0VLkpBlqm63uPtjAx6sAl399THxHVwbYgYy 27 | TxPY8wdjOvNzQJ7ColUh05Zq6TsCGGFUFg7v4to/AXtDhcTMVONlapP+XxekRx8P 28 | 98BepIgzgvQhWak8gm+cKQYANk14Q8BDzUCDplYuIZVvKl+/ZHltjHGjrqxDrcDA 29 | 0U7REgtxAoGBAN+LAEf2o14ffs/ebVSxiv7LnuAxFh2L6i7RqtehpSf7BnYC65vB 30 | 6TMsc/0/KFkD5Az7nrJmA7HmM8J/NI2ks0Mbft+0XCRFx/zfU6pOvPinRKp/8Vtm 31 | aUmNzhz8UMaQ1JXOvBOqvXKWYrN1WPha1+/BnUQrpTdhGxAoAh1FW4eHAoGBAMXA 32 | mXTN5X8+mp9KW2bIpFsjrZ+EyhxO6a6oBMZY54rceeOzf5RcXY7EOiTrnmr+lQvp 33 | fAKBeX5V8G96nSEIDmPhKGZ1C1vEP6hRWahJo1XkN5E1j6hRHCu3DQLtL2lxlyfG 34 | Fx11fysgmLoPVVytLAEQwt4WxMp7OsM1NWqB+u3tAoGBAILUg3Gas7pejIV0FGDB 35 | GCxPV8i2cc8RGBoWs/pHrLVdgUaIJwSd1LISjj/lOuP+FvZSPWsDsZ3osNpgQI21 36 | mwTnjrW2hUblYEprGjhOpOKSYum2v7dSlMRrrfng4hWUphaXTBPmlcH+qf2F7HBO 37 | GptDoZtIQAXNW111TOd8tDj5AoGAC1PO9nvcy38giENQHQEdOQNALMUEdr6mcBS7 38 | wUjSaofai4p6olrwGP9wfTDp8CMJEpebPOGBvhTaIuiZG41ElcAN+mB1+Bmzs8aF 39 | JjihnIfoDu9MfU24GWDw49wGPTn+eI7GQC+8yxGg7fd24kohHSaCowoW16pbYVco 40 | 6iLr5rkCgYBt0bcYJ3AOTH0UXS8kvJvnyce/RBIAMoUABwvdkZt9r5B4UzsoLq5e 41 | WrrU6fSRsE6lSsBd83pOAQ46tv+vntQ+0EihD9/0INhkQM99lBw1TFdFTgGSAs1e 42 | ns4JGP6f5uIuwqu/nbqPqMyDovjkGbX2znuGBcvki90Pi97XL7MMWw== 43 | -----END RSA PRIVATE KEY----- 44 | ` 45 | 46 | var fakeInvalidPrivateKey = `-----BEGIN RSA PRIVATE KEY----- 47 | MIIEpAIBAAKCAQEArK4ld6i2iqW6L3jaTZaKD/v7PjDn+Ik9MXp+kvLcUw/+wEGm 48 | 285UwqLnDDlBhSi9nDgJ+m1XU87VCpz/DXW23R/CQcMX2xunib4wWLQqoR3CWbk3 49 | SwiLd8TWAvXkxdXm8fDOGAZbYK2alMV+M+9E2OpZsBUCxmb/3FAofF6JccKoJOH8 50 | ` 51 | 52 | func TestValidateSeedJobs(t *testing.T) { 53 | data := []struct { 54 | description string 55 | jenkins *virtuslabv1alpha1.Jenkins 56 | secret *corev1.Secret 57 | expectedResult bool 58 | }{ 59 | { 60 | description: "Valid with public repository and without private key", 61 | jenkins: &virtuslabv1alpha1.Jenkins{ 62 | Spec: virtuslabv1alpha1.JenkinsSpec{ 63 | SeedJobs: []virtuslabv1alpha1.SeedJob{ 64 | { 65 | ID: "jenkins-operator-e2e", 66 | Targets: "cicd/jobs/*.jenkins", 67 | Description: "Jenkins Operator e2e tests repository", 68 | RepositoryBranch: "master", 69 | RepositoryURL: "https://github.com/VirtusLab/jenkins-operator-e2e.git", 70 | }, 71 | }, 72 | }, 73 | }, 74 | expectedResult: true, 75 | }, 76 | { 77 | description: "Invalid without id", 78 | jenkins: &virtuslabv1alpha1.Jenkins{ 79 | Spec: virtuslabv1alpha1.JenkinsSpec{ 80 | SeedJobs: []virtuslabv1alpha1.SeedJob{ 81 | { 82 | Targets: "cicd/jobs/*.jenkins", 83 | Description: "Jenkins Operator e2e tests repository", 84 | RepositoryBranch: "master", 85 | RepositoryURL: "https://github.com/VirtusLab/jenkins-operator-e2e.git", 86 | }, 87 | }, 88 | }, 89 | }, 90 | expectedResult: false, 91 | }, 92 | { 93 | description: "Valid with private key and secret", 94 | jenkins: &virtuslabv1alpha1.Jenkins{ 95 | Spec: virtuslabv1alpha1.JenkinsSpec{ 96 | SeedJobs: []virtuslabv1alpha1.SeedJob{ 97 | { 98 | ID: "jenkins-operator-e2e", 99 | Targets: "cicd/jobs/*.jenkins", 100 | Description: "Jenkins Operator e2e tests repository", 101 | RepositoryBranch: "master", 102 | RepositoryURL: "https://github.com/VirtusLab/jenkins-operator-e2e.git", 103 | PrivateKey: virtuslabv1alpha1.PrivateKey{ 104 | SecretKeyRef: &corev1.SecretKeySelector{ 105 | LocalObjectReference: corev1.LocalObjectReference{ 106 | Name: "deploy-keys", 107 | }, 108 | Key: "jenkins-operator-e2e", 109 | }, 110 | }, 111 | }, 112 | }, 113 | }, 114 | }, 115 | secret: &corev1.Secret{ 116 | TypeMeta: metav1.TypeMeta{ 117 | Kind: "Secret", 118 | APIVersion: "v1", 119 | }, 120 | ObjectMeta: metav1.ObjectMeta{ 121 | Name: "deploy-keys", 122 | Namespace: "default", 123 | }, 124 | Data: map[string][]byte{ 125 | "jenkins-operator-e2e": []byte(fakePrivateKey), 126 | }, 127 | }, 128 | expectedResult: true, 129 | }, 130 | { 131 | description: "Invalid private key in secret", 132 | jenkins: &virtuslabv1alpha1.Jenkins{ 133 | Spec: virtuslabv1alpha1.JenkinsSpec{ 134 | SeedJobs: []virtuslabv1alpha1.SeedJob{ 135 | { 136 | ID: "jenkins-operator-e2e", 137 | Targets: "cicd/jobs/*.jenkins", 138 | Description: "Jenkins Operator e2e tests repository", 139 | RepositoryBranch: "master", 140 | RepositoryURL: "https://github.com/VirtusLab/jenkins-operator-e2e.git", 141 | PrivateKey: virtuslabv1alpha1.PrivateKey{ 142 | SecretKeyRef: &corev1.SecretKeySelector{ 143 | LocalObjectReference: corev1.LocalObjectReference{ 144 | Name: "deploy-keys", 145 | }, 146 | Key: "jenkins-operator-e2e", 147 | }, 148 | }, 149 | }, 150 | }, 151 | }, 152 | }, 153 | secret: &corev1.Secret{ 154 | TypeMeta: metav1.TypeMeta{ 155 | Kind: "Secret", 156 | APIVersion: "v1", 157 | }, 158 | ObjectMeta: metav1.ObjectMeta{ 159 | Name: "deploy-keys", 160 | Namespace: "default", 161 | }, 162 | Data: map[string][]byte{ 163 | "jenkins-operator-e2e": []byte(fakeInvalidPrivateKey), 164 | }, 165 | }, 166 | expectedResult: false, 167 | }, 168 | { 169 | description: "Invalid with PrivateKey and empty Secret data", 170 | jenkins: &virtuslabv1alpha1.Jenkins{ 171 | Spec: virtuslabv1alpha1.JenkinsSpec{ 172 | SeedJobs: []virtuslabv1alpha1.SeedJob{ 173 | { 174 | ID: "jenkins-operator-e2e", 175 | Targets: "cicd/jobs/*.jenkins", 176 | Description: "Jenkins Operator e2e tests repository", 177 | RepositoryBranch: "master", 178 | RepositoryURL: "https://github.com/VirtusLab/jenkins-operator-e2e.git", 179 | PrivateKey: virtuslabv1alpha1.PrivateKey{ 180 | SecretKeyRef: &corev1.SecretKeySelector{ 181 | LocalObjectReference: corev1.LocalObjectReference{ 182 | Name: "deploy-keys", 183 | }, 184 | Key: "jenkins-operator-e2e", 185 | }, 186 | }, 187 | }, 188 | }, 189 | }, 190 | }, 191 | secret: &corev1.Secret{ 192 | TypeMeta: metav1.TypeMeta{ 193 | Kind: "Secret", 194 | APIVersion: "v1", 195 | }, 196 | ObjectMeta: metav1.ObjectMeta{ 197 | Name: "deploy-keys", 198 | Namespace: "default", 199 | }, 200 | Data: map[string][]byte{ 201 | "jenkins-operator-e2e": []byte(""), 202 | }, 203 | }, 204 | expectedResult: false, 205 | }, 206 | { 207 | description: "Invalid with ssh RepositoryURL and empty PrivateKey", 208 | jenkins: &virtuslabv1alpha1.Jenkins{ 209 | Spec: virtuslabv1alpha1.JenkinsSpec{ 210 | SeedJobs: []virtuslabv1alpha1.SeedJob{ 211 | { 212 | ID: "jenkins-operator-e2e", 213 | Targets: "cicd/jobs/*.jenkins", 214 | Description: "Jenkins Operator e2e tests repository", 215 | RepositoryBranch: "master", 216 | RepositoryURL: "git@github.com:VirtusLab/jenkins-operator.git", 217 | }, 218 | }, 219 | }, 220 | }, 221 | expectedResult: false, 222 | }, 223 | } 224 | 225 | for _, testingData := range data { 226 | t.Run(fmt.Sprintf("Testing '%s'", testingData.description), func(t *testing.T) { 227 | fakeClient := fake.NewFakeClient() 228 | if testingData.secret != nil { 229 | err := fakeClient.Create(context.TODO(), testingData.secret) 230 | assert.NoError(t, err) 231 | } 232 | userReconcileLoop := New(fakeClient, nil, logf.ZapLogger(false), nil) 233 | result, err := userReconcileLoop.validateSeedJobs(testingData.jenkins) 234 | assert.NoError(t, err) 235 | assert.Equal(t, testingData.expectedResult, result) 236 | }) 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/constants/constants.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const ( 4 | // OperatorName is a operator name 5 | OperatorName = "jenkins-operator" 6 | // DefaultAmountOfExecutors is the default amount of Jenkins executors 7 | DefaultAmountOfExecutors = 3 8 | // SeedJobSuffix is a suffix added for all seed jobs 9 | SeedJobSuffix = "job-dsl-seed" 10 | // DefaultJenkinsMasterImage is the default Jenkins master docker image 11 | DefaultJenkinsMasterImage = "jenkins/jenkins:lts" 12 | // BackupAmazonS3SecretAccessKey is the Amazon user access key used to Amazon S3 backup 13 | BackupAmazonS3SecretAccessKey = "access-key" 14 | // BackupAmazonS3SecretSecretKey is the Amazon user secret key used to Amazon S3 backup 15 | BackupAmazonS3SecretSecretKey = "secret-key" 16 | // BackupJobName is the Jenkins job name used to backup jobs history 17 | BackupJobName = OperatorName + "-backup" 18 | // UserConfigurationJobName is the Jenkins job name used to configure Jenkins by groovy scripts provided by user 19 | UserConfigurationJobName = OperatorName + "-user-configuration" 20 | // BackupLatestFileName is the latest backup file name 21 | BackupLatestFileName = "build-history-latest.tar.gz" 22 | ) 23 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/constants/labels.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const ( 4 | // LabelAppKey application Kubernetes label name 5 | LabelAppKey = "app" 6 | // LabelAppValue application Kubernetes label value 7 | LabelAppValue = OperatorName 8 | 9 | // LabelWatchKey Kubernetes label used to enable watch for reconcile loop 10 | LabelWatchKey = "watch" 11 | // LabelWatchValue Kubernetes label value to enable watch for reconcile loop 12 | LabelWatchValue = "true" 13 | 14 | // LabelJenkinsCRKey Kubernetes label name which contains Jenkins CR name 15 | LabelJenkinsCRKey = "jenkins-cr" 16 | ) 17 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/groovy/doc.go: -------------------------------------------------------------------------------- 1 | // Package groovy implements groovy scripts execution via Jenkins Job 2 | package groovy 3 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/groovy/groovy.go: -------------------------------------------------------------------------------- 1 | package groovy 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/base64" 6 | "fmt" 7 | "sort" 8 | 9 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 10 | jenkinsclient "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/client" 11 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/jobs" 12 | 13 | "github.com/go-logr/logr" 14 | k8s "sigs.k8s.io/controller-runtime/pkg/client" 15 | ) 16 | 17 | const ( 18 | jobHashParameterName = "hash" 19 | ) 20 | 21 | // Groovy defines API for groovy scripts execution via jenkins job 22 | type Groovy struct { 23 | jenkinsClient jenkinsclient.Jenkins 24 | k8sClient k8s.Client 25 | logger logr.Logger 26 | jobName string 27 | scriptsPath string 28 | } 29 | 30 | // New creates new instance of Groovy 31 | func New(jenkinsClient jenkinsclient.Jenkins, k8sClient k8s.Client, logger logr.Logger, jobName, scriptsPath string) *Groovy { 32 | return &Groovy{ 33 | jenkinsClient: jenkinsClient, 34 | k8sClient: k8sClient, 35 | logger: logger, 36 | jobName: jobName, 37 | scriptsPath: scriptsPath, 38 | } 39 | } 40 | 41 | // ConfigureGroovyJob configures jenkins job for executing groovy scripts 42 | func (g *Groovy) ConfigureGroovyJob() error { 43 | _, created, err := g.jenkinsClient.CreateOrUpdateJob(fmt.Sprintf(configurationJobXMLFmt, g.scriptsPath), g.jobName) 44 | if err != nil { 45 | return err 46 | } 47 | if created { 48 | g.logger.Info(fmt.Sprintf("'%s' job has been created", g.jobName)) 49 | } 50 | return nil 51 | } 52 | 53 | // EnsureGroovyJob executes groovy script and verifies jenkins job status according to reconciliation loop lifecycle 54 | func (g *Groovy) EnsureGroovyJob(secretOrConfigMapData map[string]string, jenkins *virtuslabv1alpha1.Jenkins) (bool, error) { 55 | jobsClient := jobs.New(g.jenkinsClient, g.k8sClient, g.logger) 56 | 57 | hash := g.calculateHash(secretOrConfigMapData) 58 | done, err := jobsClient.EnsureBuildJob(g.jobName, hash, map[string]string{jobHashParameterName: hash}, jenkins, true) 59 | if err != nil { 60 | return false, err 61 | } 62 | return done, nil 63 | } 64 | 65 | func (g *Groovy) calculateHash(secretOrConfigMapData map[string]string) string { 66 | hash := sha256.New() 67 | 68 | var keys []string 69 | for key := range secretOrConfigMapData { 70 | keys = append(keys, key) 71 | } 72 | sort.Strings(keys) 73 | for _, key := range keys { 74 | hash.Write([]byte(key)) 75 | hash.Write([]byte(secretOrConfigMapData[key])) 76 | } 77 | return base64.StdEncoding.EncodeToString(hash.Sum(nil)) 78 | } 79 | 80 | const configurationJobXMLFmt = ` 81 | 82 | 83 | 84 | false 85 | 86 | 87 | 88 | 89 | 90 | ` + jobHashParameterName + ` 91 | 92 | 93 | false 94 | 95 | 96 | 97 | 98 | 99 | 141 | false 142 | 143 | 144 | false 145 | 146 | ` 147 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/handler.go: -------------------------------------------------------------------------------- 1 | package jenkins 2 | 3 | import ( 4 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/constants" 5 | 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | "k8s.io/apimachinery/pkg/types" 8 | "k8s.io/client-go/util/workqueue" 9 | "sigs.k8s.io/controller-runtime/pkg/event" 10 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 11 | ) 12 | 13 | // enqueueRequestForJenkins enqueues a Request for secrets and configmaps created by jenkins-operator. 14 | type enqueueRequestForJenkins struct{} 15 | 16 | func (e *enqueueRequestForJenkins) Create(evt event.CreateEvent, q workqueue.RateLimitingInterface) { 17 | if req := e.getOwnerReconcileRequests(evt.Meta); req != nil { 18 | q.Add(*req) 19 | } 20 | } 21 | 22 | func (e *enqueueRequestForJenkins) Update(evt event.UpdateEvent, q workqueue.RateLimitingInterface) { 23 | if req := e.getOwnerReconcileRequests(evt.MetaOld); req != nil { 24 | q.Add(*req) 25 | } 26 | if req := e.getOwnerReconcileRequests(evt.MetaNew); req != nil { 27 | q.Add(*req) 28 | } 29 | } 30 | 31 | func (e *enqueueRequestForJenkins) Delete(evt event.DeleteEvent, q workqueue.RateLimitingInterface) { 32 | if req := e.getOwnerReconcileRequests(evt.Meta); req != nil { 33 | q.Add(*req) 34 | } 35 | } 36 | 37 | func (e *enqueueRequestForJenkins) Generic(evt event.GenericEvent, q workqueue.RateLimitingInterface) { 38 | if req := e.getOwnerReconcileRequests(evt.Meta); req != nil { 39 | q.Add(*req) 40 | } 41 | } 42 | 43 | func (e *enqueueRequestForJenkins) getOwnerReconcileRequests(object metav1.Object) *reconcile.Request { 44 | if object.GetLabels()[constants.LabelAppKey] == constants.LabelAppValue && 45 | object.GetLabels()[constants.LabelWatchKey] == constants.LabelWatchValue && 46 | len(object.GetLabels()[constants.LabelJenkinsCRKey]) > 0 { 47 | return &reconcile.Request{NamespacedName: types.NamespacedName{ 48 | Namespace: object.GetNamespace(), 49 | Name: object.GetLabels()[constants.LabelJenkinsCRKey], 50 | }} 51 | } 52 | 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/jenkins_controller.go: -------------------------------------------------------------------------------- 1 | package jenkins 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 8 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/configuration/base" 9 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/configuration/user" 10 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/constants" 11 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/plugins" 12 | "github.com/VirtusLab/jenkins-operator/pkg/event" 13 | "github.com/VirtusLab/jenkins-operator/pkg/log" 14 | 15 | "github.com/go-logr/logr" 16 | corev1 "k8s.io/api/core/v1" 17 | apierrors "k8s.io/apimachinery/pkg/api/errors" 18 | "k8s.io/apimachinery/pkg/api/resource" 19 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 20 | "k8s.io/apimachinery/pkg/runtime" 21 | "sigs.k8s.io/controller-runtime/pkg/client" 22 | "sigs.k8s.io/controller-runtime/pkg/controller" 23 | "sigs.k8s.io/controller-runtime/pkg/handler" 24 | "sigs.k8s.io/controller-runtime/pkg/manager" 25 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 26 | "sigs.k8s.io/controller-runtime/pkg/source" 27 | ) 28 | 29 | const ( 30 | // reasonBaseConfigurationSuccess is the event which informs base configuration has been completed successfully 31 | reasonBaseConfigurationSuccess event.Reason = "BaseConfigurationSuccess" 32 | // reasonUserConfigurationSuccess is the event which informs user configuration has been completed successfully 33 | reasonUserConfigurationSuccess event.Reason = "BaseConfigurationFailure" 34 | // reasonCRValidationFailure is the event which informs user has provided invalid configuration in Jenkins CR 35 | reasonCRValidationFailure event.Reason = "CRValidationFailure" 36 | ) 37 | 38 | // Add creates a new Jenkins Controller and adds it to the Manager. The Manager will set fields on the Controller 39 | // and Start it when the Manager is Started. 40 | func Add(mgr manager.Manager, local, minikube bool, events event.Recorder) error { 41 | return add(mgr, newReconciler(mgr, local, minikube, events)) 42 | } 43 | 44 | // newReconciler returns a new reconcile.Reconciler 45 | func newReconciler(mgr manager.Manager, local, minikube bool, events event.Recorder) reconcile.Reconciler { 46 | return &ReconcileJenkins{ 47 | client: mgr.GetClient(), 48 | scheme: mgr.GetScheme(), 49 | local: local, 50 | minikube: minikube, 51 | events: events, 52 | } 53 | } 54 | 55 | // add adds a new Controller to mgr with r as the reconcile.Reconciler 56 | func add(mgr manager.Manager, r reconcile.Reconciler) error { 57 | // Create a new controller 58 | c, err := controller.New("jenkins-controller", mgr, controller.Options{Reconciler: r}) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | // Watch for changes to primary resource Jenkins 64 | err = c.Watch(&source.Kind{Type: &virtuslabv1alpha1.Jenkins{}}, &handler.EnqueueRequestForObject{}) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | // Watch for changes to secondary resource Pods and requeue the owner Jenkins 70 | err = c.Watch(&source.Kind{Type: &corev1.Pod{}}, &handler.EnqueueRequestForOwner{ 71 | IsController: true, 72 | OwnerType: &virtuslabv1alpha1.Jenkins{}, 73 | }) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | jenkinsHandler := &enqueueRequestForJenkins{} 79 | err = c.Watch(&source.Kind{Type: &corev1.Secret{}}, jenkinsHandler) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | err = c.Watch(&source.Kind{Type: &corev1.ConfigMap{}}, jenkinsHandler) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | return nil 90 | } 91 | 92 | var _ reconcile.Reconciler = &ReconcileJenkins{} 93 | 94 | // ReconcileJenkins reconciles a Jenkins object 95 | type ReconcileJenkins struct { 96 | client client.Client 97 | scheme *runtime.Scheme 98 | local, minikube bool 99 | events event.Recorder 100 | } 101 | 102 | // Reconcile it's a main reconciliation loop which maintain desired state based on Jenkins.Spec 103 | func (r *ReconcileJenkins) Reconcile(request reconcile.Request) (reconcile.Result, error) { 104 | logger := r.buildLogger(request.Name) 105 | logger.V(log.VDebug).Info("Reconciling Jenkins") 106 | 107 | result, err := r.reconcile(request, logger) 108 | if err != nil && apierrors.IsConflict(err) { 109 | logger.V(log.VWarn).Info(err.Error()) 110 | return reconcile.Result{Requeue: true}, nil 111 | } else if err != nil { 112 | logger.V(log.VWarn).Info(fmt.Sprintf("Reconcile loop failed: %+v", err)) 113 | return reconcile.Result{Requeue: true}, nil 114 | } 115 | return result, nil 116 | } 117 | 118 | func (r *ReconcileJenkins) reconcile(request reconcile.Request, logger logr.Logger) (reconcile.Result, error) { 119 | // Fetch the Jenkins instance 120 | jenkins := &virtuslabv1alpha1.Jenkins{} 121 | err := r.client.Get(context.TODO(), request.NamespacedName, jenkins) 122 | if err != nil { 123 | if apierrors.IsNotFound(err) { 124 | // Request object not found, could have been deleted after reconcile request. 125 | // Owned objects are automatically garbage collected. For additional cleanup logic use finalizers. 126 | // Return and don't requeue 127 | return reconcile.Result{}, nil 128 | } 129 | // Error reading the object - requeue the request. 130 | return reconcile.Result{}, err 131 | } 132 | 133 | err = r.setDefaults(jenkins, logger) 134 | if err != nil { 135 | return reconcile.Result{}, err 136 | } 137 | 138 | // Reconcile base configuration 139 | baseConfiguration := base.New(r.client, r.scheme, logger, jenkins, r.local, r.minikube) 140 | 141 | valid, err := baseConfiguration.Validate(jenkins) 142 | if err != nil { 143 | return reconcile.Result{}, err 144 | } 145 | if !valid { 146 | r.events.Emit(jenkins, event.TypeWarning, reasonCRValidationFailure, "Base CR validation failed") 147 | logger.V(log.VWarn).Info("Validation of base configuration failed, please correct Jenkins CR") 148 | return reconcile.Result{}, nil // don't requeue 149 | } 150 | 151 | result, jenkinsClient, err := baseConfiguration.Reconcile() 152 | if err != nil { 153 | return reconcile.Result{}, err 154 | } 155 | if result.Requeue { 156 | return result, nil 157 | } 158 | 159 | if jenkins.Status.BaseConfigurationCompletedTime == nil { 160 | now := metav1.Now() 161 | jenkins.Status.BaseConfigurationCompletedTime = &now 162 | err = r.client.Update(context.TODO(), jenkins) 163 | if err != nil { 164 | return reconcile.Result{}, err 165 | } 166 | logger.Info("Base configuration phase is complete") 167 | r.events.Emit(jenkins, event.TypeNormal, reasonBaseConfigurationSuccess, "Base configuration completed") 168 | } 169 | // Reconcile user configuration 170 | userConfiguration := user.New(r.client, jenkinsClient, logger, jenkins) 171 | 172 | valid, err = userConfiguration.Validate(jenkins) 173 | if err != nil { 174 | return reconcile.Result{}, err 175 | } 176 | if !valid { 177 | logger.V(log.VWarn).Info("Validation of user configuration failed, please correct Jenkins CR") 178 | r.events.Emit(jenkins, event.TypeWarning, reasonCRValidationFailure, "User CR validation failed") 179 | return reconcile.Result{}, nil // don't requeue 180 | } 181 | 182 | result, err = userConfiguration.Reconcile() 183 | if err != nil { 184 | return reconcile.Result{}, err 185 | } 186 | if result.Requeue { 187 | return result, nil 188 | } 189 | 190 | if jenkins.Status.UserConfigurationCompletedTime == nil { 191 | now := metav1.Now() 192 | jenkins.Status.UserConfigurationCompletedTime = &now 193 | err = r.client.Update(context.TODO(), jenkins) 194 | if err != nil { 195 | return reconcile.Result{}, err 196 | } 197 | logger.Info("User configuration phase is complete") 198 | r.events.Emit(jenkins, event.TypeNormal, reasonUserConfigurationSuccess, "User configuration completed") 199 | } 200 | 201 | return reconcile.Result{}, nil 202 | } 203 | 204 | func (r *ReconcileJenkins) buildLogger(jenkinsName string) logr.Logger { 205 | return log.Log.WithValues("cr", jenkinsName) 206 | } 207 | 208 | func (r *ReconcileJenkins) setDefaults(jenkins *virtuslabv1alpha1.Jenkins, logger logr.Logger) error { 209 | changed := false 210 | if len(jenkins.Spec.Master.Image) == 0 { 211 | logger.Info("Setting default Jenkins master image: " + constants.DefaultJenkinsMasterImage) 212 | changed = true 213 | jenkins.Spec.Master.Image = constants.DefaultJenkinsMasterImage 214 | } 215 | if len(jenkins.Spec.Backup) == 0 { 216 | logger.Info("Setting default backup strategy: " + virtuslabv1alpha1.JenkinsBackupTypeNoBackup) 217 | logger.V(log.VWarn).Info("Backup is disable !!! Please configure backup in '.spec.backup'") 218 | changed = true 219 | jenkins.Spec.Backup = virtuslabv1alpha1.JenkinsBackupTypeNoBackup 220 | } 221 | if len(jenkins.Spec.Master.Plugins) == 0 { 222 | logger.Info("Setting default base plugins") 223 | changed = true 224 | jenkins.Spec.Master.Plugins = plugins.BasePlugins() 225 | } 226 | _, requestCPUSet := jenkins.Spec.Master.Resources.Requests[corev1.ResourceCPU] 227 | _, requestMemporySet := jenkins.Spec.Master.Resources.Requests[corev1.ResourceMemory] 228 | _, limitCPUSet := jenkins.Spec.Master.Resources.Limits[corev1.ResourceCPU] 229 | _, limitMemporySet := jenkins.Spec.Master.Resources.Limits[corev1.ResourceMemory] 230 | if !limitCPUSet || !limitMemporySet || !requestCPUSet || !requestMemporySet { 231 | logger.Info("Setting default Jenkins master pod resource requirements") 232 | changed = true 233 | jenkins.Spec.Master.Resources = corev1.ResourceRequirements{ 234 | Requests: corev1.ResourceList{ 235 | corev1.ResourceCPU: resource.MustParse("1"), 236 | corev1.ResourceMemory: resource.MustParse("500Mi"), 237 | }, 238 | Limits: corev1.ResourceList{ 239 | corev1.ResourceCPU: resource.MustParse("1500m"), 240 | corev1.ResourceMemory: resource.MustParse("3Gi"), 241 | }, 242 | } 243 | } 244 | 245 | if changed { 246 | return r.client.Update(context.TODO(), jenkins) 247 | } 248 | return nil 249 | } 250 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/jobs/doc.go: -------------------------------------------------------------------------------- 1 | // Package jobs implements common jenkins jobs operations 2 | package jobs 3 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/plugins/base_plugins.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | const ( 4 | // ApacheComponentsClientPlugin is apache-httpcomponents-client-4-api Jenkins plugin with version 5 | ApacheComponentsClientPlugin = "apache-httpcomponents-client-4-api:4.5.5-3.0" 6 | // Jackson2ADIPlugin is jackson2-api-httpcomponents-client-4-api Jenkins plugin with version 7 | Jackson2ADIPlugin = "jackson2-api:2.9.8" 8 | ) 9 | 10 | // BasePluginsMap contains plugins to install by operator 11 | var BasePluginsMap = map[string][]Plugin{ 12 | Must(New("kubernetes:1.13.8")).String(): { 13 | Must(New(ApacheComponentsClientPlugin)), 14 | Must(New("cloudbees-folder:6.7")), 15 | Must(New("credentials:2.1.18")), 16 | Must(New("durable-task:1.28")), 17 | Must(New(Jackson2ADIPlugin)), 18 | Must(New("kubernetes-credentials:0.4.0")), 19 | Must(New("plain-credentials:1.5")), 20 | Must(New("structs:1.17")), 21 | Must(New("variant:1.1")), 22 | Must(New("workflow-step-api:2.17")), 23 | }, 24 | Must(New("workflow-job:2.31")).String(): { 25 | Must(New("scm-api:2.3.0")), 26 | Must(New("script-security:1.50")), 27 | Must(New("structs:1.17")), 28 | Must(New("workflow-api:2.33")), 29 | Must(New("workflow-step-api:2.17")), 30 | Must(New("workflow-support:3.0")), 31 | }, 32 | Must(New("workflow-aggregator:2.6")).String(): { 33 | Must(New("ace-editor:1.1")), 34 | Must(New(ApacheComponentsClientPlugin)), 35 | Must(New("authentication-tokens:1.3")), 36 | Must(New("branch-api:2.1.2")), 37 | Must(New("cloudbees-folder:6.7")), 38 | Must(New("credentials-binding:1.17")), 39 | Must(New("credentials:2.1.18")), 40 | Must(New("display-url-api:2.3.0")), 41 | Must(New("docker-commons:1.13")), 42 | Must(New("docker-workflow:1.17")), 43 | Must(New("durable-task:1.28")), 44 | Must(New("git-client:2.7.6")), 45 | Must(New("git-server:1.7")), 46 | Must(New("handlebars:1.1.1")), 47 | Must(New(Jackson2ADIPlugin)), 48 | Must(New("jquery-detached:1.2.1")), 49 | Must(New("jsch:0.1.55")), 50 | Must(New("junit:1.26.1")), 51 | Must(New("lockable-resources:2.3")), 52 | Must(New("mailer:1.23")), 53 | Must(New("matrix-project:1.13")), 54 | Must(New("momentjs:1.1.1")), 55 | Must(New("pipeline-build-step:2.7")), 56 | Must(New("pipeline-graph-analysis:1.9")), 57 | Must(New("pipeline-input-step:2.9")), 58 | Must(New("pipeline-milestone-step:1.3.1")), 59 | Must(New("pipeline-model-api:1.3.4.1")), 60 | Must(New("pipeline-model-declarative-agent:1.1.1")), 61 | Must(New("pipeline-model-definition:1.3.4.1")), 62 | Must(New("pipeline-model-extensions:1.3.4.1")), 63 | Must(New("pipeline-rest-api:2.10")), 64 | Must(New("pipeline-stage-step:2.3")), 65 | Must(New("pipeline-stage-tags-metadata:1.3.4.1")), 66 | Must(New("pipeline-stage-view:2.10")), 67 | Must(New("plain-credentials:1.5")), 68 | Must(New("scm-api:2.3.0")), 69 | Must(New("script-security:1.50")), 70 | Must(New("ssh-credentials:1.14")), 71 | Must(New("structs:1.17")), 72 | Must(New("workflow-api:2.33")), 73 | Must(New("workflow-basic-steps:2.13")), 74 | Must(New("workflow-cps-global-lib:2.12")), 75 | Must(New("workflow-cps:2.61.1")), 76 | Must(New("workflow-durable-task-step:2.27")), 77 | Must(New("workflow-job:2.31")), 78 | Must(New("workflow-multibranch:2.20")), 79 | Must(New("workflow-scm-step:2.7")), 80 | Must(New("workflow-step-api:2.17")), 81 | Must(New("workflow-support:3.0")), 82 | }, 83 | Must(New("git:3.9.1")).String(): { 84 | Must(New(ApacheComponentsClientPlugin)), 85 | Must(New("credentials:2.1.18")), 86 | Must(New("display-url-api:2.3.0")), 87 | Must(New("git-client:2.7.6")), 88 | Must(New("jsch:0.1.55")), 89 | Must(New("junit:1.26.1")), 90 | Must(New("mailer:1.23")), 91 | Must(New("matrix-project:1.13")), 92 | Must(New("scm-api:2.3.0")), 93 | Must(New("script-security:1.50")), 94 | Must(New("ssh-credentials:1.14")), 95 | Must(New("structs:1.17")), 96 | Must(New("workflow-api:2.33")), 97 | Must(New("workflow-scm-step:2.7")), 98 | Must(New("workflow-step-api:2.17")), 99 | }, 100 | Must(New("job-dsl:1.71")).String(): { 101 | Must(New("script-security:1.50")), 102 | Must(New("structs:1.17")), 103 | }, 104 | Must(New("jobConfigHistory:2.19")).String(): {}, 105 | Must(New("configuration-as-code:1.4")).String(): { 106 | Must(New("configuration-as-code-support:1.4")), 107 | }, 108 | Must(New("simple-theme-plugin:0.5.1")).String(): {}, 109 | } 110 | 111 | // BasePlugins returns map of plugins to install by operator 112 | func BasePlugins() (plugins map[string][]string) { 113 | plugins = map[string][]string{} 114 | 115 | for rootPluginName, dependentPlugins := range BasePluginsMap { 116 | plugins[rootPluginName] = []string{} 117 | for _, pluginName := range dependentPlugins { 118 | plugins[rootPluginName] = append(plugins[rootPluginName], pluginName.String()) 119 | } 120 | } 121 | 122 | return 123 | } 124 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/plugins/plugin.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/VirtusLab/jenkins-operator/pkg/log" 8 | ) 9 | 10 | // Plugin represents jenkins plugin 11 | type Plugin struct { 12 | Name string `json:"name"` 13 | Version string `json:"version"` 14 | rootPluginNameAndVersion string 15 | } 16 | 17 | func (p Plugin) String() string { 18 | return fmt.Sprintf("%s:%s", p.Name, p.Version) 19 | } 20 | 21 | // New creates plugin from string, for example "name-of-plugin:0.0.1" 22 | func New(nameWithVersion string) (*Plugin, error) { 23 | val := strings.SplitN(nameWithVersion, ":", 2) 24 | if val == nil || len(val) != 2 { 25 | return nil, fmt.Errorf("invalid plugin format '%s'", nameWithVersion) 26 | } 27 | return &Plugin{ 28 | Name: val[0], 29 | Version: val[1], 30 | }, nil 31 | } 32 | 33 | // Must returns plugin from pointer and throws panic when error is set 34 | func Must(plugin *Plugin, err error) Plugin { 35 | if err != nil { 36 | panic(err) 37 | } 38 | 39 | return *plugin 40 | } 41 | 42 | // VerifyDependencies checks if all plugins have compatible versions 43 | func VerifyDependencies(values ...map[string][]Plugin) bool { 44 | // key - plugin name, value array of versions 45 | allPlugins := make(map[string][]Plugin) 46 | valid := true 47 | 48 | for _, value := range values { 49 | for rootPluginNameAndVersion, plugins := range value { 50 | if rootPlugin, err := New(rootPluginNameAndVersion); err != nil { 51 | valid = false 52 | } else { 53 | allPlugins[rootPlugin.Name] = append(allPlugins[rootPlugin.Name], Plugin{ 54 | Name: rootPlugin.Name, 55 | Version: rootPlugin.Version, 56 | rootPluginNameAndVersion: rootPluginNameAndVersion}) 57 | } 58 | for _, plugin := range plugins { 59 | allPlugins[plugin.Name] = append(allPlugins[plugin.Name], Plugin{ 60 | Name: plugin.Name, 61 | Version: plugin.Version, 62 | rootPluginNameAndVersion: rootPluginNameAndVersion}) 63 | } 64 | } 65 | } 66 | 67 | for pluginName, versions := range allPlugins { 68 | if len(versions) == 1 { 69 | continue 70 | } 71 | 72 | for _, firstVersion := range versions { 73 | for _, secondVersion := range versions { 74 | if firstVersion.Version != secondVersion.Version { 75 | log.Log.V(log.VWarn).Info(fmt.Sprintf("Plugin '%s' requires version '%s' but plugin '%s' requires '%s' for plugin '%s'", 76 | firstVersion.rootPluginNameAndVersion, 77 | firstVersion.Version, 78 | secondVersion.rootPluginNameAndVersion, 79 | secondVersion.Version, 80 | pluginName, 81 | )) 82 | valid = false 83 | } 84 | } 85 | } 86 | } 87 | 88 | return valid 89 | } 90 | -------------------------------------------------------------------------------- /pkg/controller/jenkins/plugins/plugin_test.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "fmt" 5 | "github.com/VirtusLab/jenkins-operator/pkg/log" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestVerifyDependencies(t *testing.T) { 12 | data := []struct { 13 | basePlugins map[string][]Plugin 14 | extraPlugins map[string][]Plugin 15 | expectedResult bool 16 | }{ 17 | { 18 | basePlugins: map[string][]Plugin{ 19 | "first-root-plugin:1.0.0": { 20 | Must(New("first-plugin:0.0.1")), 21 | }, 22 | }, 23 | expectedResult: true, 24 | }, 25 | { 26 | basePlugins: map[string][]Plugin{ 27 | "first-root-plugin:1.0.0": { 28 | Must(New("first-plugin:0.0.1")), 29 | }, 30 | "second-root-plugin:1.0.0": { 31 | Must(New("first-plugin:0.0.1")), 32 | }, 33 | }, 34 | expectedResult: true, 35 | }, 36 | { 37 | basePlugins: map[string][]Plugin{ 38 | "first-root-plugin:1.0.0": { 39 | Must(New("first-plugin:0.0.1")), 40 | }, 41 | }, 42 | extraPlugins: map[string][]Plugin{ 43 | "second-root-plugin:2.0.0": { 44 | Must(New("first-plugin:0.0.1")), 45 | }, 46 | }, 47 | expectedResult: true, 48 | }, 49 | { 50 | basePlugins: map[string][]Plugin{ 51 | "first-root-plugin:1.0.0": { 52 | Must(New("first-plugin:0.0.1")), 53 | }, 54 | "first-root-plugin:2.0.0": { 55 | Must(New("first-plugin:0.0.2")), 56 | }, 57 | }, 58 | expectedResult: false, 59 | }, 60 | { 61 | basePlugins: map[string][]Plugin{ 62 | "first-root-plugin:1.0.0": { 63 | Must(New("first-plugin:0.0.1")), 64 | }, 65 | }, 66 | extraPlugins: map[string][]Plugin{ 67 | "first-root-plugin:2.0.0": { 68 | Must(New("first-plugin:0.0.2")), 69 | }, 70 | }, 71 | expectedResult: false, 72 | }, 73 | { 74 | basePlugins: map[string][]Plugin{ 75 | "invalid-plugin-name": {}, 76 | }, 77 | expectedResult: false, 78 | }, 79 | } 80 | 81 | debug := false 82 | log.SetupLogger(&debug) 83 | 84 | for index, testingData := range data { 85 | t.Run(fmt.Sprintf("Testing %d data", index), func(t *testing.T) { 86 | result := VerifyDependencies(testingData.basePlugins, testingData.extraPlugins) 87 | assert.Equal(t, testingData.expectedResult, result) 88 | }) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /pkg/event/event.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/constants" 7 | 8 | "k8s.io/api/core/v1" 9 | "k8s.io/apimachinery/pkg/runtime" 10 | "k8s.io/client-go/kubernetes" 11 | "k8s.io/client-go/kubernetes/scheme" 12 | typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1" 13 | "k8s.io/client-go/rest" 14 | "k8s.io/client-go/tools/record" 15 | ) 16 | 17 | const ( 18 | // TypeNormal is the information event type 19 | TypeNormal = Type("Normal") 20 | // TypeWarning is the warning event type, informs that something went wrong 21 | TypeWarning = Type("Warning") 22 | ) 23 | 24 | // Type is the type of event 25 | type Type string 26 | 27 | // Reason is the type of reason message, used in evant 28 | type Reason string 29 | 30 | // Recorder is the interface used to emit events 31 | type Recorder interface { 32 | Emit(object runtime.Object, eventType Type, reason Reason, message string) 33 | Emitf(object runtime.Object, eventType Type, reason Reason, format string, args ...interface{}) 34 | } 35 | 36 | type recorder struct { 37 | recorder record.EventRecorder 38 | } 39 | 40 | // New returns recorder used to emit events 41 | func New(config *rest.Config) (Recorder, error) { 42 | eventRecorder, err := initializeEventRecorder(config) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | return &recorder{ 48 | recorder: eventRecorder, 49 | }, nil 50 | } 51 | 52 | func initializeEventRecorder(config *rest.Config) (record.EventRecorder, error) { 53 | client, err := kubernetes.NewForConfig(config) 54 | if err != nil { 55 | return nil, err 56 | } 57 | eventBroadcaster := record.NewBroadcaster() 58 | //eventBroadcaster.StartLogging(glog.Infof) TODO integrate with proper logger 59 | eventBroadcaster.StartRecordingToSink( 60 | &typedcorev1.EventSinkImpl{ 61 | Interface: client.CoreV1().Events("")}) 62 | eventRecorder := eventBroadcaster.NewRecorder( 63 | scheme.Scheme, 64 | v1.EventSource{ 65 | Component: constants.OperatorName}) 66 | return eventRecorder, nil 67 | } 68 | 69 | func (r recorder) Emit(object runtime.Object, eventType Type, reason Reason, message string) { 70 | r.recorder.Event(object, string(eventType), string(reason), message) 71 | } 72 | 73 | func (r recorder) Emitf(object runtime.Object, eventType Type, reason Reason, format string, args ...interface{}) { 74 | r.recorder.Event(object, string(eventType), string(reason), fmt.Sprintf(format, args...)) 75 | } 76 | -------------------------------------------------------------------------------- /pkg/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "sigs.k8s.io/controller-runtime/pkg/runtime/log" 5 | logf "sigs.k8s.io/controller-runtime/pkg/runtime/log" 6 | ) 7 | 8 | // Log represents global logger 9 | var Log = log.Log.WithName("controller-jenkins") 10 | 11 | const ( 12 | // VWarn defines warning log level 13 | VWarn = -1 14 | // VDebug defines debug log level 15 | VDebug = 1 16 | ) 17 | 18 | // SetupLogger setups global logger 19 | func SetupLogger(development *bool) { 20 | logf.SetLogger(logf.ZapLogger(*development)) 21 | Log = log.Log.WithName("controller-jenkins") 22 | } 23 | -------------------------------------------------------------------------------- /test/e2e/aws_s3_backup_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | "testing" 10 | 11 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 12 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/configuration/base/resources" 13 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/constants" 14 | 15 | "github.com/aws/aws-sdk-go/aws" 16 | "github.com/aws/aws-sdk-go/aws/credentials" 17 | "github.com/aws/aws-sdk-go/aws/session" 18 | "github.com/aws/aws-sdk-go/service/s3" 19 | "github.com/bndr/gojenkins" 20 | framework "github.com/operator-framework/operator-sdk/pkg/test" 21 | assert "github.com/stretchr/testify/require" 22 | corev1 "k8s.io/api/core/v1" 23 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 24 | ) 25 | 26 | type amazonS3BackupConfiguration struct { 27 | BucketName string `json:"bucketName,omitempty"` 28 | BucketPath string `json:"bucketPath,omitempty"` 29 | Region string `json:"region,omitempty"` 30 | AccessKey string `json:"accessKey,omitempty"` 31 | SecretKey string `json:"secretKey,omitempty"` 32 | } 33 | 34 | func TestAmazonS3Backup(t *testing.T) { 35 | t.Parallel() 36 | if amazonS3BackupConfigurationFile == nil || len(*amazonS3BackupConfigurationFile) == 0 { 37 | t.Skipf("Skipping testing because flag '%s' is not set", amazonS3BackupConfigurationParameterName) 38 | } 39 | backupConfig := loadAmazonS3BackupConfig(t) 40 | 41 | s3Client := createS3Client(t, backupConfig) 42 | deleteAllBackupsInS3(t, backupConfig, s3Client) 43 | namespace, ctx := setupTest(t) 44 | defer ctx.Cleanup() // Deletes test namespace 45 | 46 | jenkins := createJenkinsCRWithAmazonS3Backup(t, namespace, backupConfig) 47 | waitForJenkinsBaseConfigurationToComplete(t, jenkins) 48 | waitForJenkinsUserConfigurationToComplete(t, jenkins) 49 | 50 | restartJenkinsMasterPod(t, jenkins) 51 | waitForRecreateJenkinsMasterPod(t, jenkins) 52 | 53 | waitForJenkinsBaseConfigurationToComplete(t, jenkins) 54 | waitForJenkinsUserConfigurationToComplete(t, jenkins) 55 | jenkinsClient := verifyJenkinsAPIConnection(t, jenkins) 56 | verifyIfBackupAndRestoreWasSuccessfull(t, jenkinsClient, backupConfig, s3Client) 57 | } 58 | 59 | func createS3Client(t *testing.T, backupConfig amazonS3BackupConfiguration) *s3.S3 { 60 | sess, err := session.NewSession(&aws.Config{ 61 | Region: aws.String(backupConfig.Region), 62 | Credentials: credentials.NewStaticCredentials(backupConfig.AccessKey, backupConfig.SecretKey, ""), 63 | }) 64 | assert.NoError(t, err) 65 | 66 | return s3.New(sess) 67 | } 68 | 69 | func deleteAllBackupsInS3(t *testing.T, backupConfig amazonS3BackupConfiguration, s3Client *s3.S3) { 70 | input := &s3.DeleteObjectInput{ 71 | Bucket: aws.String(backupConfig.BucketName), 72 | Key: aws.String(backupConfig.BucketPath), 73 | } 74 | 75 | _, err := s3Client.DeleteObject(input) 76 | assert.NoError(t, err) 77 | } 78 | 79 | func verifyIfBackupAndRestoreWasSuccessfull(t *testing.T, jenkinsClient *gojenkins.Jenkins, backupConfig amazonS3BackupConfiguration, s3Client *s3.S3) { 80 | job, err := jenkinsClient.GetJob(constants.UserConfigurationJobName) 81 | assert.NoError(t, err) 82 | // jenkins runs twice(2) + 1 as next build number 83 | assert.Equal(t, int64(3), job.Raw.NextBuildNumber) 84 | 85 | listObjects, err := s3Client.ListObjects(&s3.ListObjectsInput{ 86 | Bucket: aws.String(backupConfig.BucketName), 87 | Marker: aws.String(backupConfig.BucketPath), 88 | }) 89 | assert.NoError(t, err) 90 | t.Logf("Backups in S3:%+v", listObjects.Contents) 91 | assert.Equal(t, len(listObjects.Contents), 2) 92 | latestBackupFound := false 93 | for _, backup := range listObjects.Contents { 94 | if *backup.Key == fmt.Sprintf("%s/%s", backupConfig.BucketPath, constants.BackupLatestFileName) { 95 | latestBackupFound = true 96 | } 97 | } 98 | assert.True(t, latestBackupFound) 99 | } 100 | 101 | func createJenkinsCRWithAmazonS3Backup(t *testing.T, namespace string, backupConfig amazonS3BackupConfiguration) *virtuslabv1alpha1.Jenkins { 102 | jenkins := &virtuslabv1alpha1.Jenkins{ 103 | ObjectMeta: metav1.ObjectMeta{ 104 | Name: "e2e", 105 | Namespace: namespace, 106 | }, 107 | Spec: virtuslabv1alpha1.JenkinsSpec{ 108 | Backup: virtuslabv1alpha1.JenkinsBackupTypeAmazonS3, 109 | BackupAmazonS3: virtuslabv1alpha1.JenkinsBackupAmazonS3{ 110 | Region: backupConfig.Region, 111 | BucketPath: backupConfig.BucketPath, 112 | BucketName: backupConfig.BucketName, 113 | }, 114 | Master: virtuslabv1alpha1.JenkinsMaster{ 115 | Image: "jenkins/jenkins", 116 | }, 117 | }, 118 | } 119 | 120 | t.Logf("Jenkins CR %+v", *jenkins) 121 | err := framework.Global.Client.Create(context.TODO(), jenkins, nil) 122 | assert.NoError(t, err) 123 | 124 | backupCredentialsSecret := &corev1.Secret{ 125 | ObjectMeta: metav1.ObjectMeta{ 126 | Name: resources.GetBackupCredentialsSecretName(jenkins), 127 | Namespace: namespace, 128 | }, 129 | Data: map[string][]byte{ 130 | constants.BackupAmazonS3SecretAccessKey: []byte(backupConfig.AccessKey), 131 | constants.BackupAmazonS3SecretSecretKey: []byte(backupConfig.SecretKey), 132 | }, 133 | } 134 | err = framework.Global.Client.Create(context.TODO(), backupCredentialsSecret, nil) 135 | assert.NoError(t, err) 136 | 137 | return jenkins 138 | } 139 | 140 | func loadAmazonS3BackupConfig(t *testing.T) amazonS3BackupConfiguration { 141 | jsonFile, err := os.Open(*amazonS3BackupConfigurationFile) 142 | assert.NoError(t, err) 143 | defer func() { _ = jsonFile.Close() }() 144 | 145 | byteValue, err := ioutil.ReadAll(jsonFile) 146 | assert.NoError(t, err) 147 | 148 | var result amazonS3BackupConfiguration 149 | err = json.Unmarshal([]byte(byteValue), &result) 150 | assert.NoError(t, err) 151 | assert.NotEmpty(t, result.AccessKey) 152 | assert.NotEmpty(t, result.BucketName) 153 | assert.NotEmpty(t, result.Region) 154 | assert.NotEmpty(t, result.SecretKey) 155 | result.BucketPath = t.Name() 156 | return result 157 | } 158 | -------------------------------------------------------------------------------- /test/e2e/base_configuration_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | 8 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 9 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/plugins" 10 | 11 | "github.com/bndr/gojenkins" 12 | framework "github.com/operator-framework/operator-sdk/pkg/test" 13 | corev1 "k8s.io/api/core/v1" 14 | "k8s.io/apimachinery/pkg/api/resource" 15 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | ) 17 | 18 | func TestBaseConfiguration(t *testing.T) { 19 | t.Parallel() 20 | namespace, ctx := setupTest(t) 21 | // Deletes test namespace 22 | defer ctx.Cleanup() 23 | 24 | jenkins := createJenkinsCR(t, namespace) 25 | createDefaultLimitsForContainersInNamespace(t, namespace) 26 | waitForJenkinsBaseConfigurationToComplete(t, jenkins) 27 | 28 | verifyJenkinsMasterPodAttributes(t, jenkins) 29 | jenkinsClient := verifyJenkinsAPIConnection(t, jenkins) 30 | verifyBasePlugins(t, jenkinsClient) 31 | } 32 | 33 | func createDefaultLimitsForContainersInNamespace(t *testing.T, namespace string) { 34 | limitRange := &corev1.LimitRange{ 35 | ObjectMeta: metav1.ObjectMeta{ 36 | Name: "e2e", 37 | Namespace: namespace, 38 | }, 39 | Spec: corev1.LimitRangeSpec{ 40 | Limits: []corev1.LimitRangeItem{ 41 | { 42 | Type: corev1.LimitTypeContainer, 43 | DefaultRequest: map[corev1.ResourceName]resource.Quantity{ 44 | corev1.ResourceCPU: resource.MustParse("1"), 45 | corev1.ResourceMemory: resource.MustParse("1Gi"), 46 | }, 47 | Default: map[corev1.ResourceName]resource.Quantity{ 48 | corev1.ResourceCPU: resource.MustParse("4"), 49 | corev1.ResourceMemory: resource.MustParse("4Gi"), 50 | }, 51 | }, 52 | }, 53 | }, 54 | } 55 | 56 | t.Logf("LimitRange %+v", *limitRange) 57 | if err := framework.Global.Client.Create(context.TODO(), limitRange, nil); err != nil { 58 | t.Fatal(err) 59 | } 60 | } 61 | 62 | func verifyJenkinsMasterPodAttributes(t *testing.T, jenkins *virtuslabv1alpha1.Jenkins) { 63 | jenkinsPod := getJenkinsMasterPod(t, jenkins) 64 | jenkins = getJenkins(t, jenkins.Namespace, jenkins.Name) 65 | 66 | for key, value := range jenkins.Spec.Master.Annotations { 67 | if jenkinsPod.ObjectMeta.Annotations[key] != value { 68 | t.Fatalf("Invalid Jenkins pod annotation expected '%+v', actual '%+v'", jenkins.Spec.Master.Annotations, jenkinsPod.ObjectMeta.Annotations) 69 | } 70 | } 71 | 72 | if jenkinsPod.Spec.Containers[0].Image != jenkins.Spec.Master.Image { 73 | t.Fatalf("Invalid jenkins pod image expected '%s', actual '%s'", jenkins.Spec.Master.Image, jenkinsPod.Spec.Containers[0].Image) 74 | } 75 | 76 | if !reflect.DeepEqual(jenkinsPod.Spec.Containers[0].Resources, jenkins.Spec.Master.Resources) { 77 | t.Fatalf("Invalid jenkins pod continer resources expected '%+v', actual '%+v'", jenkins.Spec.Master.Resources, jenkinsPod.Spec.Containers[0].Resources) 78 | } 79 | 80 | t.Log("Jenkins pod attributes are valid") 81 | } 82 | 83 | func verifyBasePlugins(t *testing.T, jenkinsClient *gojenkins.Jenkins) { 84 | installedPlugins, err := jenkinsClient.GetPlugins(1) 85 | if err != nil { 86 | t.Fatal(err) 87 | } 88 | 89 | for rootPluginName, p := range plugins.BasePluginsMap { 90 | rootPlugin, err := plugins.New(rootPluginName) 91 | if err != nil { 92 | t.Fatal(err) 93 | } 94 | if found, ok := isPluginValid(installedPlugins, *rootPlugin); !ok { 95 | t.Fatalf("Invalid plugin '%s', actual '%+v'", rootPlugin, found) 96 | } 97 | for _, requiredPlugin := range p { 98 | if found, ok := isPluginValid(installedPlugins, requiredPlugin); !ok { 99 | t.Fatalf("Invalid plugin '%s', actual '%+v'", requiredPlugin, found) 100 | } 101 | } 102 | } 103 | 104 | t.Log("Base plugins have been installed") 105 | } 106 | 107 | func isPluginValid(plugins *gojenkins.Plugins, requiredPlugin plugins.Plugin) (*gojenkins.Plugin, bool) { 108 | p := plugins.Contains(requiredPlugin.Name) 109 | if p == nil { 110 | return p, false 111 | } 112 | 113 | if !p.Active || !p.Enabled || p.Deleted { 114 | return p, false 115 | } 116 | 117 | return p, requiredPlugin.Version == p.Version 118 | } 119 | -------------------------------------------------------------------------------- /test/e2e/jenkins.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | 9 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 10 | jenkinsclient "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/client" 11 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/configuration/base/resources" 12 | 13 | "github.com/bndr/gojenkins" 14 | framework "github.com/operator-framework/operator-sdk/pkg/test" 15 | "k8s.io/api/core/v1" 16 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 17 | "k8s.io/apimachinery/pkg/labels" 18 | "k8s.io/apimachinery/pkg/types" 19 | ) 20 | 21 | func getJenkins(t *testing.T, namespace, name string) *virtuslabv1alpha1.Jenkins { 22 | jenkins := &virtuslabv1alpha1.Jenkins{} 23 | namespaceName := types.NamespacedName{Namespace: namespace, Name: name} 24 | if err := framework.Global.Client.Get(context.TODO(), namespaceName, jenkins); err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | return jenkins 29 | } 30 | 31 | func getJenkinsMasterPod(t *testing.T, jenkins *virtuslabv1alpha1.Jenkins) *v1.Pod { 32 | lo := metav1.ListOptions{ 33 | LabelSelector: labels.SelectorFromSet(resources.BuildResourceLabels(jenkins)).String(), 34 | } 35 | podList, err := framework.Global.KubeClient.CoreV1().Pods(jenkins.ObjectMeta.Namespace).List(lo) 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | if len(podList.Items) != 1 { 40 | t.Fatalf("Jenkins pod not found, pod list: %+v", podList) 41 | } 42 | return &podList.Items[0] 43 | } 44 | 45 | func createJenkinsAPIClient(jenkins *virtuslabv1alpha1.Jenkins) (*gojenkins.Jenkins, error) { 46 | adminSecret := &v1.Secret{} 47 | namespaceName := types.NamespacedName{Namespace: jenkins.Namespace, Name: resources.GetOperatorCredentialsSecretName(jenkins)} 48 | if err := framework.Global.Client.Get(context.TODO(), namespaceName, adminSecret); err != nil { 49 | return nil, err 50 | } 51 | 52 | jenkinsAPIURL, err := jenkinsclient.BuildJenkinsAPIUrl(jenkins.ObjectMeta.Namespace, resources.GetResourceName(jenkins), resources.HTTPPortInt, true, true) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | jenkinsClient := gojenkins.CreateJenkins( 58 | nil, 59 | jenkinsAPIURL, 60 | string(adminSecret.Data[resources.OperatorCredentialsSecretUserNameKey]), 61 | string(adminSecret.Data[resources.OperatorCredentialsSecretTokenKey]), 62 | ) 63 | if _, err := jenkinsClient.Init(); err != nil { 64 | return nil, err 65 | } 66 | 67 | status, err := jenkinsClient.Poll() 68 | if err != nil { 69 | return nil, err 70 | } 71 | if status != http.StatusOK { 72 | return nil, fmt.Errorf("invalid status code returned: %d", status) 73 | } 74 | 75 | return jenkinsClient, nil 76 | } 77 | 78 | func createJenkinsCR(t *testing.T, namespace string) *virtuslabv1alpha1.Jenkins { 79 | jenkins := &virtuslabv1alpha1.Jenkins{ 80 | ObjectMeta: metav1.ObjectMeta{ 81 | Name: "e2e", 82 | Namespace: namespace, 83 | }, 84 | Spec: virtuslabv1alpha1.JenkinsSpec{ 85 | Master: virtuslabv1alpha1.JenkinsMaster{ 86 | Image: "jenkins/jenkins", 87 | Annotations: map[string]string{"test": "label"}, 88 | }, 89 | }, 90 | } 91 | 92 | t.Logf("Jenkins CR %+v", *jenkins) 93 | if err := framework.Global.Client.Create(context.TODO(), jenkins, nil); err != nil { 94 | t.Fatal(err) 95 | } 96 | 97 | return jenkins 98 | } 99 | 100 | func createJenkinsCRWithSeedJob(t *testing.T, namespace string) *virtuslabv1alpha1.Jenkins { 101 | jenkins := &virtuslabv1alpha1.Jenkins{ 102 | ObjectMeta: metav1.ObjectMeta{ 103 | Name: "e2e", 104 | Namespace: namespace, 105 | }, 106 | Spec: virtuslabv1alpha1.JenkinsSpec{ 107 | Master: virtuslabv1alpha1.JenkinsMaster{ 108 | Image: "jenkins/jenkins", 109 | Annotations: map[string]string{"test": "label"}, 110 | }, 111 | //TODO(bantoniak) add seed job with private key 112 | SeedJobs: []virtuslabv1alpha1.SeedJob{ 113 | { 114 | ID: "jenkins-operator", 115 | Targets: "cicd/jobs/*.jenkins", 116 | Description: "Jenkins Operator repository", 117 | RepositoryBranch: "master", 118 | RepositoryURL: "https://github.com/VirtusLab/jenkins-operator.git", 119 | }, 120 | }, 121 | }, 122 | } 123 | 124 | t.Logf("Jenkins CR %+v", *jenkins) 125 | if err := framework.Global.Client.Create(context.TODO(), jenkins, nil); err != nil { 126 | t.Fatal(err) 127 | } 128 | 129 | return jenkins 130 | } 131 | 132 | func verifyJenkinsAPIConnection(t *testing.T, jenkins *virtuslabv1alpha1.Jenkins) *gojenkins.Jenkins { 133 | client, err := createJenkinsAPIClient(jenkins) 134 | if err != nil { 135 | t.Fatal(err) 136 | } 137 | 138 | t.Log("I can establish connection to Jenkins API") 139 | return client 140 | } 141 | 142 | func restartJenkinsMasterPod(t *testing.T, jenkins *virtuslabv1alpha1.Jenkins) { 143 | t.Log("Restarting Jenkins master pod") 144 | jenkinsPod := getJenkinsMasterPod(t, jenkins) 145 | err := framework.Global.Client.Delete(context.TODO(), jenkinsPod) 146 | if err != nil { 147 | t.Fatal(err) 148 | } 149 | t.Log("Jenkins master pod has been restarted") 150 | } 151 | -------------------------------------------------------------------------------- /test/e2e/main_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "flag" 5 | "testing" 6 | 7 | "github.com/VirtusLab/jenkins-operator/pkg/apis" 8 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 9 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/constants" 10 | 11 | f "github.com/operator-framework/operator-sdk/pkg/test" 12 | framework "github.com/operator-framework/operator-sdk/pkg/test" 13 | "github.com/operator-framework/operator-sdk/pkg/test/e2eutil" 14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | ) 16 | 17 | const ( 18 | jenkinsOperatorDeploymentName = constants.OperatorName 19 | amazonS3BackupConfigurationParameterName = "s3BackupConfig" 20 | ) 21 | 22 | var ( 23 | amazonS3BackupConfigurationFile *string 24 | ) 25 | 26 | func TestMain(m *testing.M) { 27 | amazonS3BackupConfigurationFile = flag.String(amazonS3BackupConfigurationParameterName, "", "path to AWS S3 backup config") 28 | f.MainEntry(m) 29 | } 30 | 31 | func setupTest(t *testing.T) (string, *framework.TestCtx) { 32 | ctx := framework.NewTestCtx(t) 33 | err := ctx.InitializeClusterResources(nil) 34 | if err != nil { 35 | t.Fatalf("could not initialize cluster resources: %v", err) 36 | } 37 | 38 | jenkinsServiceList := &virtuslabv1alpha1.JenkinsList{ 39 | TypeMeta: metav1.TypeMeta{ 40 | Kind: virtuslabv1alpha1.Kind, 41 | APIVersion: virtuslabv1alpha1.SchemeGroupVersion.String(), 42 | }, 43 | } 44 | err = framework.AddToFrameworkScheme(apis.AddToScheme, jenkinsServiceList) 45 | if err != nil { 46 | t.Fatalf("could not add scheme to framework scheme: %v", err) 47 | } 48 | 49 | namespace, err := ctx.GetNamespace() 50 | if err != nil { 51 | t.Fatalf("could not get namespace: %v", err) 52 | } 53 | t.Logf("Test namespace '%s'", namespace) 54 | 55 | // wait for jenkins-operator to be ready 56 | err = e2eutil.WaitForDeployment(t, framework.Global.KubeClient, namespace, jenkinsOperatorDeploymentName, 1, retryInterval, timeout) 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | return namespace, ctx 62 | } 63 | -------------------------------------------------------------------------------- /test/e2e/restart_pod_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 8 | 9 | framework "github.com/operator-framework/operator-sdk/pkg/test" 10 | "k8s.io/apimachinery/pkg/types" 11 | ) 12 | 13 | func TestJenkinsMasterPodRestart(t *testing.T) { 14 | t.Parallel() 15 | namespace, ctx := setupTest(t) 16 | // Deletes test namespace 17 | defer ctx.Cleanup() 18 | 19 | jenkins := createJenkinsCR(t, namespace) 20 | waitForJenkinsBaseConfigurationToComplete(t, jenkins) 21 | restartJenkinsMasterPod(t, jenkins) 22 | waitForRecreateJenkinsMasterPod(t, jenkins) 23 | checkBaseConfigurationCompleteTimeIsNotSet(t, jenkins) 24 | waitForJenkinsBaseConfigurationToComplete(t, jenkins) 25 | } 26 | 27 | func checkBaseConfigurationCompleteTimeIsNotSet(t *testing.T, jenkins *virtuslabv1alpha1.Jenkins) { 28 | jenkinsStatus := &virtuslabv1alpha1.Jenkins{} 29 | namespaceName := types.NamespacedName{Namespace: jenkins.Namespace, Name: jenkins.Name} 30 | err := framework.Global.Client.Get(context.TODO(), namespaceName, jenkinsStatus) 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | if jenkinsStatus.Status.BaseConfigurationCompletedTime != nil { 35 | t.Fatalf("Status.BaseConfigurationCompletedTime is set after pod restart, status %+v", jenkinsStatus.Status) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/e2e/user_configuration_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 9 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/configuration/user/seedjobs" 10 | 11 | "github.com/bndr/gojenkins" 12 | framework "github.com/operator-framework/operator-sdk/pkg/test" 13 | "github.com/stretchr/testify/assert" 14 | "k8s.io/apimachinery/pkg/types" 15 | "k8s.io/apimachinery/pkg/util/wait" 16 | ) 17 | 18 | func TestUserConfiguration(t *testing.T) { 19 | t.Parallel() 20 | namespace, ctx := setupTest(t) 21 | // Deletes test namespace 22 | defer ctx.Cleanup() 23 | 24 | // base 25 | jenkins := createJenkinsCRWithSeedJob(t, namespace) 26 | waitForJenkinsBaseConfigurationToComplete(t, jenkins) 27 | client := verifyJenkinsAPIConnection(t, jenkins) 28 | 29 | // user 30 | waitForJenkinsUserConfigurationToComplete(t, jenkins) 31 | verifyJenkinsSeedJobs(t, client, jenkins) 32 | } 33 | 34 | func verifyJenkinsSeedJobs(t *testing.T, client *gojenkins.Jenkins, jenkins *virtuslabv1alpha1.Jenkins) { 35 | t.Logf("Attempting to get configure seed job status '%v'", seedjobs.ConfigureSeedJobsName) 36 | 37 | configureSeedJobs, err := client.GetJob(seedjobs.ConfigureSeedJobsName) 38 | assert.NoError(t, err) 39 | assert.NotNil(t, configureSeedJobs) 40 | build, err := configureSeedJobs.GetLastSuccessfulBuild() 41 | assert.NoError(t, err) 42 | assert.NotNil(t, build) 43 | 44 | seedJobName := "jenkins-operator-configure-seed-job" 45 | t.Logf("Attempting to verify if seed job has been created '%v'", seedJobName) 46 | seedJob, err := client.GetJob(seedJobName) 47 | assert.NoError(t, err) 48 | assert.NotNil(t, seedJob) 49 | 50 | build, err = seedJob.GetLastSuccessfulBuild() 51 | assert.NoError(t, err) 52 | assert.NotNil(t, build) 53 | 54 | err = framework.Global.Client.Get(context.TODO(), types.NamespacedName{Namespace: jenkins.Namespace, Name: jenkins.Name}, jenkins) 55 | assert.NoError(t, err, "couldn't get jenkins custom resource") 56 | assert.NotNil(t, jenkins.Status.Builds) 57 | assert.NotEmpty(t, jenkins.Status.Builds) 58 | 59 | jobCreatedByDSLPluginName := "build-jenkins-operator" 60 | err = wait.Poll(time.Second*10, time.Minute*2, func() (bool, error) { 61 | t.Logf("Attempting to verify if job '%s' has been created ", jobCreatedByDSLPluginName) 62 | seedJob, err := client.GetJob(jobCreatedByDSLPluginName) 63 | if err != nil || seedJob == nil { 64 | return false, nil 65 | } 66 | return true, nil 67 | }) 68 | assert.NoError(t, err) 69 | } 70 | -------------------------------------------------------------------------------- /test/e2e/wait.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | goctx "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | virtuslabv1alpha1 "github.com/VirtusLab/jenkins-operator/pkg/apis/virtuslab/v1alpha1" 10 | "github.com/VirtusLab/jenkins-operator/pkg/controller/jenkins/configuration/base/resources" 11 | 12 | framework "github.com/operator-framework/operator-sdk/pkg/test" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/apimachinery/pkg/labels" 15 | "k8s.io/apimachinery/pkg/types" 16 | "k8s.io/apimachinery/pkg/util/wait" 17 | ) 18 | 19 | var ( 20 | retryInterval = time.Second * 5 21 | timeout = time.Second * 60 22 | ) 23 | 24 | // checkConditionFunc is used to check if a condition for the jenkins CR is true 25 | type checkConditionFunc func(*virtuslabv1alpha1.Jenkins) bool 26 | 27 | func waitForJenkinsBaseConfigurationToComplete(t *testing.T, jenkins *virtuslabv1alpha1.Jenkins) { 28 | t.Log("Waiting for Jenkins base configuration to complete") 29 | _, err := WaitUntilJenkinsConditionTrue(retryInterval, 150, jenkins, func(jenkins *virtuslabv1alpha1.Jenkins) bool { 30 | t.Logf("Current Jenkins status '%+v'", jenkins.Status) 31 | return jenkins.Status.BaseConfigurationCompletedTime != nil 32 | }) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | t.Log("Jenkins pod is running") 37 | } 38 | 39 | func waitForRecreateJenkinsMasterPod(t *testing.T, jenkins *virtuslabv1alpha1.Jenkins) { 40 | err := wait.Poll(retryInterval, 30*retryInterval, func() (bool, error) { 41 | lo := metav1.ListOptions{ 42 | LabelSelector: labels.SelectorFromSet(resources.BuildResourceLabels(jenkins)).String(), 43 | } 44 | podList, err := framework.Global.KubeClient.CoreV1().Pods(jenkins.ObjectMeta.Namespace).List(lo) 45 | if err != nil { 46 | return false, err 47 | } 48 | if len(podList.Items) != 1 { 49 | return false, nil 50 | } 51 | 52 | return podList.Items[0].DeletionTimestamp == nil, nil 53 | }) 54 | if err != nil { 55 | t.Fatal(err) 56 | } 57 | t.Log("Jenkins pod has been recreated") 58 | } 59 | 60 | func waitForJenkinsUserConfigurationToComplete(t *testing.T, jenkins *virtuslabv1alpha1.Jenkins) { 61 | t.Log("Waiting for Jenkins user configuration to complete") 62 | _, err := WaitUntilJenkinsConditionTrue(retryInterval, 30, jenkins, func(jenkins *virtuslabv1alpha1.Jenkins) bool { 63 | t.Logf("Current Jenkins status '%+v'", jenkins.Status) 64 | return jenkins.Status.UserConfigurationCompletedTime != nil 65 | }) 66 | if err != nil { 67 | t.Fatal(err) 68 | } 69 | t.Log("Jenkins pod is running") 70 | } 71 | 72 | // WaitUntilJenkinsConditionTrue retries until the specified condition check becomes true for the jenkins CR 73 | func WaitUntilJenkinsConditionTrue(retryInterval time.Duration, retries int, jenkins *virtuslabv1alpha1.Jenkins, checkCondition checkConditionFunc) (*virtuslabv1alpha1.Jenkins, error) { 74 | jenkinsStatus := &virtuslabv1alpha1.Jenkins{} 75 | err := wait.Poll(retryInterval, time.Duration(retries)*retryInterval, func() (bool, error) { 76 | namespacedName := types.NamespacedName{Namespace: jenkins.Namespace, Name: jenkins.Name} 77 | err := framework.Global.Client.Get(goctx.TODO(), namespacedName, jenkinsStatus) 78 | if err != nil { 79 | return false, fmt.Errorf("failed to get CR: %v", err) 80 | } 81 | return checkCondition(jenkinsStatus), nil 82 | }) 83 | if err != nil { 84 | return nil, err 85 | } 86 | return jenkinsStatus, nil 87 | } 88 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | // Version indicates which version of the binary is running. 4 | var Version string 5 | 6 | // GitCommit indicates which git hash the binary was built off of 7 | var GitCommit string 8 | --------------------------------------------------------------------------------