diff --git a/docs/plugins/jenkins-pipeline-kubernetes.zh.md b/docs/plugins/jenkins-pipeline-kubernetes.zh.md index 1b9d4a3d2..ccefb8840 100644 --- a/docs/plugins/jenkins-pipeline-kubernetes.zh.md +++ b/docs/plugins/jenkins-pipeline-kubernetes.zh.md @@ -4,11 +4,10 @@ 步骤: -1. 访问 Jenkins web UI,创建 token。步骤:People -> admin ->Configure -> API Token -> Add new Token。 -2. 按需修改配置项,其中 `githubRepoUrl` 为 GitHub 仓库地址,应预先建立一个 GitHub 仓库,并创建一个名为 "Jenkinsfile" 的文件放至仓库根目录。 -3. 设置环境变量 +1. 按需修改配置项,其中 `githubRepoUrl` 为 GitHub 仓库地址,应预先建立一个 GitHub 仓库,并创建一个名为 "Jenkinsfile" 的文件放至仓库根目录。 +2. 设置环境变量 - `GITHUB_TOKEN` - - `JENKINS_TOKEN` + - `JENKINS_PASSWORD` ## 用例 @@ -18,4 +17,103 @@ ``` -目前,所有选项均为必填项。 +## 和 `jenkins` 插件一起使用 + +这个插件可以和 `jenkins` 插件一起使用,[`jenkins` 插件文档](./jenkins.zh.md)。 + +即在安装完 `Jenkins` 后,再建立 `Jenkins` job。 + +首先根据 `dependsOn` 设定插件依赖,再根据 `${{jenkins.default.outputs.jenkinsURL}}` 和 `${{jenkins.default.outputs.jenkinsPasswordOfAdmin}}` 设置 Jenkins 的 URL 和 admin 密码。 + +注意:如果你的 Kubernetes 集群是 K8s in docker 模式,请自行设置网络,确保 `jenkins` 插件中 `NodePort` 设置的端口在宿主机内能访问。 + +```yaml +--- +tools: + # name of the tool + - name: jenkins + # id of the tool instance + instanceID: default + # format: name.instanceID; If specified, dtm will make sure the dependency is applied first before handling this tool. + dependsOn: [ ] + # options for the plugin + options: + # if true, the plugin will use hostpath to create a pv named `jenkins-pv` + # and you should create the volumes directory manually, see plugin doc for details. + test_env: false + # need to create the namespace or not, default: false + create_namespace: false + # Helm repo information + repo: + # name of the Helm repo + name: jenkins + # url of the Helm repo + url: https://charts.jenkins.io + # Helm chart information + chart: + # name of the chart + chart_name: jenkins/jenkins + # release name of the chart + release_name: dev + # k8s namespace where jenkins will be installed + namespace: jenkins + # whether to wait for the release to be deployed or not + wait: true + # the time to wait for any individual Kubernetes operation (like Jobs for hooks). This defaults to 5m0s + timeout: 5m + # whether to perform a CRD upgrade during installation + upgradeCRDs: true + # custom configuration. You can refer to [Jenkins values.yaml](https://github.com/jenkinsci/helm-charts/blob/main/charts/jenkins/values.yaml) + values_yaml: | + persistence: + # for prod env: the existent storageClass, please change it + # for test env: just ignore it, but don't remove it + storageClass: jenkins-pv + serviceAccount: + create: false + name: jenkins + controller: + serviceType: NodePort + nodePort: 32000 + additionalPlugins: + # install "GitHub Pull Request Builder" plugin, see https://plugins.jenkins.io/ghprb/ for more details + - ghprb + # install "OWASP Markup Formatter" plugin, see https://plugins.jenkins.io/antisamy-markup-formatter/ for more details + - antisamy-markup-formatter + # Enable HTML parsing using OWASP Markup Formatter Plugin (antisamy-markup-formatter), useful with ghprb plugin. + enableRawHtmlMarkupFormatter: true + # Jenkins Configuraction as Code, refer to https://plugins.jenkins.io/configuration-as-code/ for more details + # notice: All configuration files that are discovered MUST be supplementary. They cannot overwrite each other's configuration values. This creates a conflict and raises a ConfiguratorException. + JCasC: + defaultConfig: true + # each key-value in configScripts will be added to the ${JENKINS_HOME}/casc_configs/ directory as a file. + configScripts: + # this will create a file named "safe_html.yaml" in the ${JENKINS_HOME}/casc_configs/ directory. + # it is used to configure the "Safe HTML" plugin. + # filename must meet RFC 1123, see https://tools.ietf.org/html/rfc1123 for more details + - name: jenkins-pipeline-kubernetes + # id of the tool instance + instanceID: default + # format: name.instanceID; If specified, dtm will make sure the dependency is applied first before handling this tool. + dependsOn: [ "jenkins.default" ] + # options for the plugin + options: + jenkins: + # jenkinsUrl, format: hostname:port + url: ${{jenkins.default.outputs.jenkinsURL}} + # jenkins user name, default: admin + user: admin + # jenkins password, you have 3 options to set the password: + # 1. use outputs of the `jenkins` plugin, see docs for more details + # 2. set the `JENKINS_PASSWORD` environment variable + # 3. fill in the password in this field(not recommended) + # if all set, devstream will read the password from the config file or outputs from jenkins plugin first, then env var. + password: ${{jenkins.default.outputs.jenkinsPasswordOfAdmin}} + # jenkins job name, mandatory + jobName: + # path to the pipeline file, relative to the git repo root directory. default: Jenkinsfile + pipelineScriptPath: Jenkinsfile + # github repo url where the pipeline script is located. mandatory + githubRepoUrl: https://github.com/xxx/xxx.git +``` + diff --git a/docs/plugins/jenkins.md b/docs/plugins/jenkins.md index 1de434ddf..b472f4ad5 100644 --- a/docs/plugins/jenkins.md +++ b/docs/plugins/jenkins.md @@ -47,3 +47,10 @@ chown -R 1000:1000 /data/jenkins-volume/ ``` Currently, all the parameters in the example above are mandatory. + +## Outputs + +This plugin has two outputs: + +- `jenkinsURL` (format: `hostname:port`, example: "localhost:8080") +- `jenkinsPasswordOfAdmin` diff --git a/docs/plugins/jenkins.zh.md b/docs/plugins/jenkins.zh.md index 6c685b514..0381699df 100644 --- a/docs/plugins/jenkins.zh.md +++ b/docs/plugins/jenkins.zh.md @@ -47,3 +47,10 @@ chown -R 1000:1000 /data/jenkins-volume/ ``` 当前,所有配置项均为必填。 + +## 输出 + +这个插件有两个输出: + +- `jenkinsURL` (格式: `hostname:port`, 例如: "localhost:8080") +- `jenkinsPasswordOfAdmin` diff --git a/go.mod b/go.mod index 157843c10..747bdd7bd 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/aws/aws-sdk-go-v2 v1.16.3 github.com/aws/aws-sdk-go-v2/config v1.15.5 github.com/aws/aws-sdk-go-v2/service/s3 v1.26.9 + github.com/bndr/gojenkins v1.1.0 github.com/cenkalti/backoff v2.2.1+incompatible github.com/cheggaaa/pb v1.0.29 github.com/deckarep/golang-set/v2 v2.1.0 @@ -27,7 +28,7 @@ require ( github.com/spf13/viper v1.8.1 github.com/stretchr/testify v1.7.0 github.com/tcnksm/go-input v0.0.0-20180404061846-548a7d7a8ee8 - github.com/withfig/autocomplete-tools/integrations/cobra v0.0.0-20220705165518-2761d7f4b8bc + github.com/withfig/autocomplete-tools/integrations/cobra v0.0.0-20220721102007-67b2515c5ea4 github.com/xanzy/go-gitlab v0.55.1 golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 diff --git a/go.sum b/go.sum index 366888300..c5ca0029a 100644 --- a/go.sum +++ b/go.sum @@ -213,6 +213,8 @@ github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnweb github.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/bndr/gojenkins v1.1.0 h1:TWyJI6ST1qDAfH33DQb3G4mD8KkrBfyfSUoZBHQAvPI= +github.com/bndr/gojenkins v1.1.0/go.mod h1:QeskxN9F/Csz0XV/01IC8y37CapKKWvOHa0UHLLX1fM= github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= github.com/bombsimon/logrusr v1.0.0 h1:CTCkURYAt5nhCCnKH9eLShYayj2/8Kn/4Qg3QfiU+Ro= github.com/bombsimon/logrusr v1.0.0/go.mod h1:Jq0nHtvxabKE5EMwAAdgTaz7dfWE8C4i11NOltxGQpc= @@ -1268,8 +1270,8 @@ github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV github.com/vmware/govmomi v0.20.3/go.mod h1:URlwyTFZX72RmxtxuaFL2Uj3fD1JTvZdx59bHWk6aFU= github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI= -github.com/withfig/autocomplete-tools/integrations/cobra v0.0.0-20220705165518-2761d7f4b8bc h1:2pGkMttK5jQ8+6YhdyeQIHyVa84HMdJhILozImSWX6c= -github.com/withfig/autocomplete-tools/integrations/cobra v0.0.0-20220705165518-2761d7f4b8bc/go.mod h1:nmuySobZb4kFgFy6BptpXp/BBw+xFSyvVPP6auoJB4k= +github.com/withfig/autocomplete-tools/integrations/cobra v0.0.0-20220721102007-67b2515c5ea4 h1:GoQuMyofxqcdyzWqVhsv5IsCL+wHoocrhUgzhtB2Nj4= +github.com/withfig/autocomplete-tools/integrations/cobra v0.0.0-20220721102007-67b2515c5ea4/go.mod h1:nmuySobZb4kFgFy6BptpXp/BBw+xFSyvVPP6auoJB4k= github.com/xanzy/go-gitlab v0.55.1 h1:IgX/DS9buV0AUz8fuJPQkdl0fQGfBiAsAHxpun8sNhg= github.com/xanzy/go-gitlab v0.55.1/go.mod h1:F0QEXwmqiBUxCgJm8fE9S+1veX4XC9Z4cfaAbqwk4YM= github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= diff --git a/internal/pkg/plugin/jenkins/create.go b/internal/pkg/plugin/jenkins/create.go index fcbb19396..360e06d45 100644 --- a/internal/pkg/plugin/jenkins/create.go +++ b/internal/pkg/plugin/jenkins/create.go @@ -26,7 +26,7 @@ func Create(options map[string]interface{}) (map[string]interface{}, error) { TermateOperations: []plugininstaller.BaseOperation{ helm.DealWithNsWhenInterruption, }, - GetStatusOperation: helm.GetPluginStaticStateByReleaseNameWrapper(defaultStatefulsetTplList), + GetStatusOperation: wrapperHelmResourceAndCustomResource(helm.GetPluginStaticStateByReleaseNameWrapper(defaultStatefulsetTplList)), } // 2. execute installer get status and error diff --git a/internal/pkg/plugin/jenkins/jenkins.go b/internal/pkg/plugin/jenkins/jenkins.go index b11309f68..352a1e1a5 100644 --- a/internal/pkg/plugin/jenkins/jenkins.go +++ b/internal/pkg/plugin/jenkins/jenkins.go @@ -1,6 +1,57 @@ package jenkins +import "github.com/devstream-io/devstream/internal/pkg/plugininstaller" + var defaultStatefulsetTplList = []string{ // ${release-name}-jenkins "%s-jenkins", } + +// wrapperHelmResourceAndCustomResource wraps helm resource and custom resource, +// this is due to the limitation of `plugininstaller`, +// now `plugininstaller.GetStatusOperation` only support one resource get function, +// if we want to use both existing resource get function(such as helm's methods) and custom function, +// we have to wrap them into one function. +func wrapperHelmResourceAndCustomResource(helmResFunc plugininstaller.StatusOperation) plugininstaller.StatusOperation { + return func(options plugininstaller.RawOptions) (map[string]interface{}, error) { + opts, err := newOptions(options) + if err != nil { + return nil, err + } + + // 1. get helm resource + resource, err := helmResFunc(options) + if err != nil { + return nil, err + } + + // 2. get custom resource, and merge with helm resource + outputs := map[string]interface{}{} + // 2.1 get jenkins url + // TODO(aFlyBird0): TestEnv is not strictly as same as "K8s in docker" + if !opts.TestEnv { + jenkinsURL, err := getJenkinsURL(options) + if err != nil { + return nil, err + } + outputs["jenkinsURL"] = jenkinsURL + } else { + jenkinsURLForTestEnv, err := getJenkinsURLForTestEnv(options) + if err != nil { + return nil, err + } + outputs["jenkinsURL"] = jenkinsURLForTestEnv + } + + // 2.2 get jenkins password of admin + jenkinsPassword, err := getPasswdOfAdmin(options) + if err != nil { + return nil, err + } + outputs["jenkinsPasswordOfAdmin"] = jenkinsPassword + + resource["outputs"] = outputs + + return resource, nil + } +} diff --git a/internal/pkg/plugin/jenkins/prompt.go b/internal/pkg/plugin/jenkins/prompt.go index c05c00e95..74858dc1e 100644 --- a/internal/pkg/plugin/jenkins/prompt.go +++ b/internal/pkg/plugin/jenkins/prompt.go @@ -9,38 +9,130 @@ import ( "github.com/devstream-io/devstream/pkg/util/log" ) +func buildPasswdOfAdminCommand(opts jenkinsOptions) string { + method := fmt.Sprintf("kubectl exec --namespace jenkins -it svc/%s-jenkins -c jenkins "+ + "-- /bin/cat /run/secrets/additional/chart-admin-password && echo", opts.Chart.ReleaseName) + + return method +} + func howToGetPasswdOfAdmin(options plugininstaller.RawOptions) error { opts, err := newOptions(options) if err != nil { return err } + log.Info("Here is how to get the password of the admin user:") - method := fmt.Sprintf("kubectl exec --namespace jenkins -it svc/%s-jenkins -c jenkins "+ - "-- /bin/cat /run/secrets/additional/chart-admin-password && echo", opts.Chart.ReleaseName) - log.Info(method) + command := buildPasswdOfAdminCommand(opts) + log.Info(command) + return nil } -func showJenkinsUrl(options plugininstaller.RawOptions) error { +func getPasswdOfAdmin(options plugininstaller.RawOptions) (string, error) { opts, err := newOptions(options) if err != nil { - return err + return "", err + } + + commandString := buildPasswdOfAdminCommand(opts) + command := exec.Command("sh", "-c", commandString) + + password, err := command.Output() + if err != nil { + return "", fmt.Errorf("failed to get password of admin user: %v", err) } + + return strings.TrimSpace(string(password)), nil + +} + +// getJenkinsURL returns the jenkins url of the jenkins, format: hostname:port +func getJenkinsURL(options plugininstaller.RawOptions) (string, error) { + opts, err := newOptions(options) + if err != nil { + return "", err + } + commands := []string{ `jsonpath="{.spec.ports[0].nodePort}"`, fmt.Sprintf(`NODE_PORT=$(kubectl get -n jenkins -o jsonpath=$jsonpath services %s-jenkins)`, opts.Chart.ReleaseName), `jsonpath="{.items[0].status.addresses[0].address}"`, `NODE_IP=$(kubectl get nodes -n jenkins -o jsonpath=$jsonpath)`, - `echo http://$NODE_IP:$NODE_PORT/login`, + `echo $NODE_IP:$NODE_PORT`, } cmd := exec.Command("sh", "-c", strings.Join(commands, " && ")) output, err := cmd.Output() if err != nil { - log.Errorf("Failed to get jenkins url: %v", err) + return "", fmt.Errorf("Failed to get jenkins url: %v", err) + } + + return strings.TrimSpace(string(output)), nil +} + +// getJenkinsURL behaves like getJenkinsURL, but the hostname will be always 127.0.0.1 +func getJenkinsURLForTestEnv(options plugininstaller.RawOptions) (string, error) { + opts, err := newOptions(options) + if err != nil { + return "", err + } + + commands := []string{ + `jsonpath="{.spec.ports[0].nodePort}"`, + fmt.Sprintf(`NODE_PORT=$(kubectl get -n jenkins -o jsonpath=$jsonpath services %s-jenkins)`, opts.Chart.ReleaseName), + `jsonpath="{.items[0].status.addresses[0].address}"`, + `NODE_IP=127.0.0.1`, + `echo $NODE_IP:$NODE_PORT`, + } + + cmd := exec.Command("sh", "-c", strings.Join(commands, " && ")) + + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("Failed to get jenkins url: %v", err) + } + + return strings.TrimSpace(string(output)), nil +} + +func showJenkinsUrl(options plugininstaller.RawOptions) error { + opts, err := newOptions(options) + if err != nil { + return err + } + + // prod env: just print Jenkins url + if !opts.TestEnv { + url, err := getJenkinsURL(options) + if err != nil { + log.Error(err) + return err + } + + log.Infof("Jenkins url: http://%s/login", url) + } + + // test env: print Jenkins url in host machine and Jenkins url in K8s cluster + if opts.TestEnv { + log.Info("You are in test env. Here are the Jenkins url in host machine and Jenkins url in K8s cluster.") + + urlForTestEnv, err := getJenkinsURLForTestEnv(options) + if err != nil { + log.Error(err) + return err + } + log.Infof("Jenkins url in host machine: http://%s/login", urlForTestEnv) + + urlInK8s, err := getJenkinsURL(options) + if err != nil { + log.Error(err) + return err + } + log.Info("Jenkins url in K8s:", fmt.Sprintf("http://%s/login", urlInK8s)) + } - log.Info("Jenkins url:", string(output)) return nil } diff --git a/internal/pkg/plugin/jenkins/read.go b/internal/pkg/plugin/jenkins/read.go index 966235eae..be2e9b029 100644 --- a/internal/pkg/plugin/jenkins/read.go +++ b/internal/pkg/plugin/jenkins/read.go @@ -13,7 +13,7 @@ func Read(options map[string]interface{}) (map[string]interface{}, error) { helm.Validate, replaceStroageClass, }, - GetStatusOperation: helm.GetPluginAllState, + GetStatusOperation: wrapperHelmResourceAndCustomResource(helm.GetPluginAllState), } status, err := runner.Execute(plugininstaller.RawOptions(options)) diff --git a/internal/pkg/plugin/jenkins/update.go b/internal/pkg/plugin/jenkins/update.go index d29dc5674..401036e7d 100644 --- a/internal/pkg/plugin/jenkins/update.go +++ b/internal/pkg/plugin/jenkins/update.go @@ -20,7 +20,7 @@ func Update(options map[string]interface{}) (map[string]interface{}, error) { TermateOperations: []plugininstaller.BaseOperation{ helm.DealWithNsWhenInterruption, }, - GetStatusOperation: helm.GetPluginStaticStateByReleaseNameWrapper(defaultStatefulsetTplList), + GetStatusOperation: wrapperHelmResourceAndCustomResource(helm.GetPluginStaticStateByReleaseNameWrapper(defaultStatefulsetTplList)), } // 2. execute installer get status and error diff --git a/internal/pkg/plugin/jenkinspipelinekubernetes/client.go b/internal/pkg/plugin/jenkinspipelinekubernetes/client.go deleted file mode 100644 index 2a537517e..000000000 --- a/internal/pkg/plugin/jenkinspipelinekubernetes/client.go +++ /dev/null @@ -1,153 +0,0 @@ -package jenkinspipelinekubernetes - -import ( - _ "embed" - "fmt" - "os/exec" - "strings" - - "github.com/parnurzeal/gorequest" - - "github.com/devstream-io/devstream/pkg/util/log" -) - -// ClientInf represents the client abstraction for jenkins -type ClientInf interface { - CreateCredentialSecretText() error - CreateCredentialUsernamePassword() error - GetCrumb() (string, error) - GetCrumbHeader() (headerKey, headerValue string, err error) - CreateItem(jobXmlContent string) error -} - -// Client is the client for jenkins -type Client struct { - Opts *Options -} - -func NewClient(options *Options) *Client { - return &Client{ - Opts: options, - } -} - -//func (c *Client) ifCredentialExists() bool { -// // todo -// return false -//} - -// CreateCredentialSecretText creates a credential in the type of "Secret text" -func (c *Client) CreateCredentialSecretText() error { - - accessURL := c.Opts.GetJenkinsAccessURL() - crumb, err := c.GetCrumb() - if err != nil { - return fmt.Errorf("failed to create credential secret: %s", err) - } - - // TODO(aFlyBird0): use gorequest to do the request - cmdCreateCredential := fmt.Sprintf(` -curl -H %s -X POST '%s/credentials/store/system/domain/_/createCredentials' \ ---data-urlencode 'json={ - "": "0", - "credentials": { - "scope": "GLOBAL", - "id": "%s", - "secret": "%s", - "description": "%s", - "$class": "org.jenkinsci.plugins.plaincredentials.impl.StringCredentialsImpl" - } -}'`, crumb, accessURL, jenkinsCredentialID, c.Opts.GitHubToken, jenkinsCredentialDesc) - - cmd := exec.Command("sh", "-c", cmdCreateCredential) - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to create credential secret: %s", err) - } - - return nil -} - -// CreateCredentialUsernamePassword creates a credential in the type of "Username with password" -func (c *Client) CreateCredentialUsernamePassword() error { - - accessURL := c.Opts.GetJenkinsAccessURL() - crumb, err := c.GetCrumb() - if err != nil { - return fmt.Errorf("failed to create credential secret: %s", err) - } - - // TODO(aFlyBird0): use gorequest to do the request - cmdCreateCredential := fmt.Sprintf(` -curl -H %s -X POST '%s/credentials/store/system/domain/_/createCredentials' \ ---data-urlencode 'json={ - "": "0", - "credentials": { - "scope": "GLOBAL", - "id": "%s", - "username": "foo-useless-username", - "password": "%s", - "description": "%s", - "$class": "com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl" - } -}'`, crumb, accessURL, jenkinsCredentialID, c.Opts.GitHubToken, jenkinsCredentialDesc) - - cmd := exec.Command("sh", "-c", cmdCreateCredential) - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to create credential secret: %s", err) - } - - return nil -} - -// GetCrumb returns the crumb for jenkins, -// jenkins uses crumb to prevent CSRF(cross-site request forgery), -// format: "Jenkins-Crumb:a70290b6423777f0a4c771d4805637ac36d5fd78336a20d48d72167ef5f13b9a" -// ref: https://www.jenkins.io/doc/upgrade-guide/2.176/#upgrading-to-jenkins-lts-2-176-3 -// ref: https://stackoverflow.com/questions/44711696/jenkins-403-no-valid-crumb-was-included-in-the-request -func (c *Client) GetCrumb() (string, error) { - request := gorequest.New() - getCrumbURL := c.Opts.GetJenkinsAccessURL() + `/crumbIssuer/api/xml?xpath=concat(//crumbRequestField,":",//crumb)` - resp, body, errs := request.Get(getCrumbURL).End() - log.Debugf("GetCrumb url: %s", getCrumbURL) - if len(errs) != 0 { - return "", fmt.Errorf("failed to get crumb: %s", errs) - } - if resp.StatusCode != 200 { - return "", fmt.Errorf("failed to get crumb, here is response: %s", body) - } - - return strings.TrimSpace(body), nil -} - -// GetCrumbHeader behaves like GetCrumb, but it returns the header key and value -func (c *Client) GetCrumbHeader() (headerKey, headerValue string, err error) { - // crumb format: "Jenkins-Crumb:a70290b6423777f0a4c771d4805637ac36d5fd78336a20d48d72167ef5f13b9a" - crumb, err := c.GetCrumb() - if err != nil { - return "", "", err - } - crumbMap := strings.Split(crumb, ":") - if len(crumbMap) != 2 { - return "", "", fmt.Errorf("failed to get crumb, here is response: %s", crumb) - } - return crumbMap[0], crumbMap[1], nil -} - -//go:embed job-template.xml -var jobTemplate string - -// CreateItem creates a job in jenkins with the given job xml -func (c *Client) CreateItem(jobXmlContent string) error { - request := gorequest.New() - resp, body, errs := request.Post(c.Opts.GetJenkinsAccessURL()+"/createItem"). - Set("Content-Type", "application/xml"). - Query("name=" + c.Opts.JenkinsJobName).Send(jobXmlContent).End() - - if len(errs) != 0 { - return fmt.Errorf("failed to create job: %s", errs) - } else if resp.StatusCode != 200 { - return fmt.Errorf("failed to create job, here is response: %s", body) - } - - return nil -} diff --git a/internal/pkg/plugin/jenkinspipelinekubernetes/create.go b/internal/pkg/plugin/jenkinspipelinekubernetes/create.go index 505a0346e..284971155 100644 --- a/internal/pkg/plugin/jenkinspipelinekubernetes/create.go +++ b/internal/pkg/plugin/jenkinspipelinekubernetes/create.go @@ -1,6 +1,7 @@ package jenkinspipelinekubernetes import ( + "context" _ "embed" "fmt" "strings" @@ -11,8 +12,9 @@ import ( ) const ( - jenkinsCredentialID = "credential-jenkins-pipeline-kubernetes-by-devstream" - jenkinsCredentialDesc = "Jenkins Pipeline secret, created by devstream/jenkins-pipeline-kubernetes" + jenkinsCredentialID = "credential-jenkins-pipeline-kubernetes-by-devstream" + jenkinsCredentialDesc = "Jenkins Pipeline secret, created by devstream/jenkins-pipeline-kubernetes" + jenkinsCredentialUsername = "foo-useless-username" ) func Create(options map[string]interface{}) (map[string]interface{}, error) { @@ -21,26 +23,40 @@ func Create(options map[string]interface{}) (map[string]interface{}, error) { return nil, err } - if errs := validate(&opts); len(errs) != 0 { + if errs := validateAndHandleOptions(&opts); len(errs) != 0 { for _, e := range errs { log.Errorf("Options error: %s.", e) } return nil, fmt.Errorf("opts are illegal") } - client := NewClient(&opts) - - // always try to create credential, there will be no error if it already exists - if err := client.CreateCredentialUsernamePassword(); err != nil { + // get the jenkins client and test the connection + client, err := NewJenkinsFromOptions(&opts) + if err != nil { return nil, err } - jobXmlContent := renderJobXml(jobTemplate, &opts) - - // TODO(aFlyBird0): check if the job already exists + // create credential if not exists + if _, err := client.GetCredentialsUsername(jenkinsCredentialID); err != nil { + log.Infof("credential %s not found, creating...", jenkinsCredentialID) + if err := client.CreateCredentialsUsername(jenkinsCredentialUsername, opts.GitHubToken, jenkinsCredentialID, jenkinsCredentialDesc); err != nil { + return nil, err + } + } - if err := client.CreateItem(jobXmlContent); err != nil { - return nil, fmt.Errorf("failed to create job: %s", err) + // create job if not exists + ctx := context.Background() + if _, err := client.GetJob(ctx, opts.J.JobName); err != nil { + log.Infof("job %s not found, creating...", opts.J.JobName) + jobXmlOpts := &JobXmlOptions{ + GitHubRepoURL: opts.GitHubRepoURL, + CredentialsID: jenkinsCredentialID, + PipelineScriptPath: opts.J.PipelineScriptPath, + } + jobXmlContent := renderJobXml(jobTemplate, jobXmlOpts) + if _, err := client.CreateJob(context.Background(), jobXmlContent, opts.J.JobName); err != nil { + return nil, fmt.Errorf("failed to create job: %s", err) + } } // TODO(aFlyBird0): use JCasC to configure job creation @@ -78,21 +94,33 @@ func Create(options map[string]interface{}) (map[string]interface{}, error) { // 4. add "read" functions to the ConfigMap to get the content of the ConfigMap and check if the resource is drifted // 5. maybe we also should consider to expose some config key in ConfigMap to the user - // TODO(aFlyBird0): build dtm resource - // TODO(aFlyBird0): what if user doesn't use helm to install jenkins? then the JCasC may not be automatically reloaded. - return (&resource{}).toMap(), nil + res := &resource{ + CredentialsCreated: true, + JobCreated: true, + } + + return res.toMap(), nil } -// TODO(aFlyBird0): unit test -// TODO(aFlyBird0): now jenkins script path is hardcoded to "Jenkinsfile", it should be configurable -func renderJobXml(jobTemplate string, opts *Options) string { - // note: maybe it is better to use html/template to generate the job template, - // but that way is complex and this is the simplest way to do it - jobXml := strings.Replace(jobTemplate, "{{.GitHubRepoURL}}", opts.GitHubRepoURL, 1) - jobXml = strings.Replace(jobXml, "{{.CredentialsID}}", jenkinsCredentialID, 1) +type JobXmlOptions struct { + GitHubRepoURL string + CredentialsID string + PipelineScriptPath string +} + +func renderJobXml(jobTemplate string, opts *JobXmlOptions) string { + // TODO(aFlyBird0): use html/template to generate the job template. It's a good first issue. :) + replacerSlice := []string{ + "{{.GitHubRepoURL}}", opts.GitHubRepoURL, + "{{.CredentialsID}}", opts.CredentialsID, + "{{.PipelineScriptPath}}", opts.PipelineScriptPath, + } + + jobXml := strings.NewReplacer(replacerSlice...).Replace(jobTemplate) log.Debugf("job xml rendered: %s", jobXml) + return jobXml } diff --git a/internal/pkg/plugin/jenkinspipelinekubernetes/create_test.go b/internal/pkg/plugin/jenkinspipelinekubernetes/create_test.go new file mode 100644 index 000000000..9748fbdd5 --- /dev/null +++ b/internal/pkg/plugin/jenkinspipelinekubernetes/create_test.go @@ -0,0 +1,54 @@ +package jenkinspipelinekubernetes + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Create", func() { + Describe("renderJobXml function", func() { + var opts *JobXmlOptions + BeforeEach(func() { + opts = &JobXmlOptions{ + GitHubRepoURL: "https://github.com/xxx/jenkins-file-test.git", + CredentialsID: "credential-jenkins-pipeline-kubernetes-by-devstream", + PipelineScriptPath: "this-is-pipeline-script-path", + } + }) + + It("should return the correct xml", func() { + xml := renderJobXml(jobTemplate, opts) + expect := ` + + + false + + + + 2 + + + https://github.com/xxx/jenkins-file-test.git + credential-jenkins-pipeline-kubernetes-by-devstream + + + + + */main + + + false + + + + this-is-pipeline-script-path + false + + + false + +` + Expect(xml).To(Equal(expect)) + }) + }) +}) diff --git a/internal/pkg/plugin/jenkinspipelinekubernetes/delete.go b/internal/pkg/plugin/jenkinspipelinekubernetes/delete.go index 49d3a1910..48a9d885b 100644 --- a/internal/pkg/plugin/jenkinspipelinekubernetes/delete.go +++ b/internal/pkg/plugin/jenkinspipelinekubernetes/delete.go @@ -1,6 +1,7 @@ package jenkinspipelinekubernetes import ( + "context" "fmt" "github.com/mitchellh/mapstructure" @@ -14,16 +15,34 @@ func Delete(options map[string]interface{}) (bool, error) { return false, err } - if errs := validate(&opts); len(errs) != 0 { + if errs := validateAndHandleOptions(&opts); len(errs) != 0 { for _, e := range errs { log.Errorf("Options error: %s.", e) } return false, fmt.Errorf("opts are illegal") } - // TODO(aFlyBird0): use helm uninstall to delete the resource created by devstream // TODO(aFlyBird0): filter ConfigMaps Created by devstream in the "jenkins" namespace and delete them(filter by label) - // TODO(aFlyBird0): delete other resources created by devstream(if exists) + + // get the jenkins client and test the connection + client, err := NewJenkinsFromOptions(&opts) + if err != nil { + return false, err + } + + // delete the credentials created by devstream if exists + if _, err := client.GetCredentialsUsername(jenkinsCredentialID); err == nil { + if err := client.DeleteCredentialsUsername(jenkinsCredentialID); err != nil { + return false, err + } + } + + // delete the job created by devstream if exists + if _, err = client.GetJob(context.Background(), opts.J.JobName); err == nil { + if _, err := client.DeleteJob(context.Background(), opts.J.JobName); err != nil { + return false, err + } + } return true, nil } diff --git a/internal/pkg/plugin/jenkinspipelinekubernetes/jenkinspipelinekubernetes.go b/internal/pkg/plugin/jenkinspipelinekubernetes/jenkinspipelinekubernetes.go index 7dafaac88..c1fdc5bdc 100644 --- a/internal/pkg/plugin/jenkinspipelinekubernetes/jenkinspipelinekubernetes.go +++ b/internal/pkg/plugin/jenkinspipelinekubernetes/jenkinspipelinekubernetes.go @@ -1,9 +1,30 @@ package jenkinspipelinekubernetes -// TODO(aFlyBird0): specify the resource fields here. +import ( + _ "embed" + + "github.com/devstream-io/devstream/pkg/util/jenkins" +) + +//go:embed job-template.xml +var jobTemplate string + +// NewJenkinsFromOptions creates a Jenkins client from the given options and test the connection. +func NewJenkinsFromOptions(opts *Options) (*jenkins.Jenkins, error) { + return jenkins.NewJenkins(opts.J.URL, opts.J.User, opts.J.Password) +} + +// TODO(aFlyBird0): enhance the resource fields here to be read and the way to read it, such as: +// plugins install info(GitHub Pull Request Builder Plugin and OWASP Markup Formatter must be installed) +// should we keep an eye on job configuration && status changes? maybe not. type resource struct { + CredentialsCreated bool + JobCreated bool } func (res *resource) toMap() map[string]interface{} { - return map[string]interface{}{} + return map[string]interface{}{ + "credentialsCreated": res.CredentialsCreated, + "jobCreated": res.JobCreated, + } } diff --git a/internal/pkg/plugin/jenkinspipelinekubernetes/jenkinspipelinekubernetes_suite_test.go b/internal/pkg/plugin/jenkinspipelinekubernetes/jenkinspipelinekubernetes_suite_test.go index 0b0c314a0..ea0efe51c 100644 --- a/internal/pkg/plugin/jenkinspipelinekubernetes/jenkinspipelinekubernetes_suite_test.go +++ b/internal/pkg/plugin/jenkinspipelinekubernetes/jenkinspipelinekubernetes_suite_test.go @@ -1,4 +1,4 @@ -package jenkinspipelinekubernetes_test +package jenkinspipelinekubernetes import ( "testing" diff --git a/internal/pkg/plugin/jenkinspipelinekubernetes/job-template.xml b/internal/pkg/plugin/jenkinspipelinekubernetes/job-template.xml index 3fcf24ee2..271f8dc67 100644 --- a/internal/pkg/plugin/jenkinspipelinekubernetes/job-template.xml +++ b/internal/pkg/plugin/jenkinspipelinekubernetes/job-template.xml @@ -21,7 +21,7 @@ - Jenkinsfile + {{.PipelineScriptPath}} false diff --git a/internal/pkg/plugin/jenkinspipelinekubernetes/options.go b/internal/pkg/plugin/jenkinspipelinekubernetes/options.go index e61878fa0..48454d0cf 100644 --- a/internal/pkg/plugin/jenkinspipelinekubernetes/options.go +++ b/internal/pkg/plugin/jenkinspipelinekubernetes/options.go @@ -1,18 +1,21 @@ package jenkinspipelinekubernetes -import "fmt" +const ( + defaultJenkinsUser = "admin" + defaultJenkinsPipelineScriptPath = "Jenkinsfile" +) // Options is the struct for configurations of the jenkins-pipeline-kubernetes plugin. type Options struct { - JenkinsURL string `mapstructure:"jenkinsUrl" validate:"required,hostname_port"` - JenkinsUser string `mapstructure:"jenkinsUser" validate:"required"` - JenkinsToken string `mapstructure:"jenkinsToken"` - GitHubToken string `mapstructure:"githubToken"` - GitHubRepoURL string `mapstructure:"githubRepoUrl" validate:"required"` - JenkinsJobName string `mapstructure:"jenkinsJobName" validate:"required"` - // TODO(aFlyBird0): add options to configure the script path in GitHub repo, now it is hardcoded to "Jenkinsfile" + J *JenkinsOption `mapstructure:"jenkins"` + GitHubToken string `mapstructure:"githubToken"` + GitHubRepoURL string `mapstructure:"githubRepoUrl" validateAndHandleOptions:"required"` } -func (options *Options) GetJenkinsAccessURL() string { - return fmt.Sprintf("http://%s:%s@%s", options.JenkinsUser, options.JenkinsToken, options.JenkinsURL) +type JenkinsOption struct { + URL string `mapstructure:"url" validateAndHandleOptions:"required,hostname_port"` + User string `mapstructure:"user" validateAndHandleOptions:"required"` + Password string `mapstructure:"password"` + JobName string `mapstructure:"jobName"` + PipelineScriptPath string `mapstructure:"pipelineScriptPath"` } diff --git a/internal/pkg/plugin/jenkinspipelinekubernetes/read.go b/internal/pkg/plugin/jenkinspipelinekubernetes/read.go index 18ea8f638..855e73513 100644 --- a/internal/pkg/plugin/jenkinspipelinekubernetes/read.go +++ b/internal/pkg/plugin/jenkinspipelinekubernetes/read.go @@ -1,6 +1,7 @@ package jenkinspipelinekubernetes import ( + "context" "fmt" "github.com/mitchellh/mapstructure" @@ -14,19 +15,28 @@ func Read(options map[string]interface{}) (map[string]interface{}, error) { return nil, err } - if errs := validate(&opts); len(errs) != 0 { + if errs := validateAndHandleOptions(&opts); len(errs) != 0 { for _, e := range errs { log.Errorf("Options error: %s.", e) } return nil, fmt.Errorf("opts are illegal") } - // TODO(aFlyBird0): specify the resource to be read and the way to read it, such as: - // plugins install info(GitHub Pull Request Builder Plugin and OWASP Markup Formatter must be installed) - // job list(filter the jobs which are created by devstream) - // credential list(filter the credentials which are created by devstream)(if credential created by devstream exists) - // JCasC configuration - // job configuration && status + // get the jenkins client and test the connection + client, err := NewJenkinsFromOptions(&opts) + if err != nil { + return nil, err + } + + res := &resource{} + + if _, err = client.GetCredentialsUsername(jenkinsCredentialID); err == nil { + res.CredentialsCreated = true + } + + if _, err = client.GetJob(context.Background(), opts.J.JobName); err == nil { + res.JobCreated = true + } - return nil, nil + return res.toMap(), nil } diff --git a/internal/pkg/plugin/jenkinspipelinekubernetes/update.go b/internal/pkg/plugin/jenkinspipelinekubernetes/update.go index dfc7f07fd..2715770bf 100644 --- a/internal/pkg/plugin/jenkinspipelinekubernetes/update.go +++ b/internal/pkg/plugin/jenkinspipelinekubernetes/update.go @@ -14,7 +14,7 @@ func Update(options map[string]interface{}) (map[string]interface{}, error) { return nil, err } - if errs := validate(&opts); len(errs) != 0 { + if errs := validateAndHandleOptions(&opts); len(errs) != 0 { for _, e := range errs { log.Errorf("Options error: %s.", e) } @@ -26,5 +26,8 @@ func Update(options map[string]interface{}) (map[string]interface{}, error) { // some, we should only call some update function // others, we just ignore them - return (&resource{}).toMap(), nil + // now we just use the same way as create, + // because the logic is the same: "if not exists, create; if exists, do nothing" + // if it changes in the future, we should change the way to update + return Create(options) } diff --git a/internal/pkg/plugin/jenkinspipelinekubernetes/validate.go b/internal/pkg/plugin/jenkinspipelinekubernetes/validate.go index e0c291737..ea884e43e 100644 --- a/internal/pkg/plugin/jenkinspipelinekubernetes/validate.go +++ b/internal/pkg/plugin/jenkinspipelinekubernetes/validate.go @@ -3,43 +3,64 @@ package jenkinspipelinekubernetes import ( "fmt" "os" - "strings" "github.com/devstream-io/devstream/pkg/util/validator" ) -// validate validates the options provided by the core. -func validate(options *Options) []error { +// validateAndHandleOptions validates and pre handle the options provided by the core. +func validateAndHandleOptions(options *Options) []error { + validateErrs := validate(options) + + defaults(options) + + envErrs := handleEnv(options) + + return append(validateErrs, envErrs...) +} - // pre-handle options to remove "http://" from JenkinsURL - preHandleOptions(options) +func validate(options *Options) []error { + return validator.Struct(options) +} - var retErrs []error +func defaults(options *Options) { + // pre handle the options + if options.J.PipelineScriptPath == "" { + options.J.PipelineScriptPath = defaultJenkinsPipelineScriptPath + } - if errs := validator.Struct(options); len(errs) != 0 { - retErrs = append(retErrs, errs...) + if options.J.User == "" { + options.J.User = defaultJenkinsUser } + options.J.URL = "http://" + options.J.URL +} + +func handleEnv(options *Options) []error { + var errs []error + options.GitHubToken = os.Getenv("GITHUB_TOKEN") + if options.GitHubToken == "" { + errs = append(errs, fmt.Errorf("env GITHUB_TOKEN is required")) + } // TODO(aFlyBird0): now jenkins token should be provided by the user, // so, user should install jenkins first and stop to set the token in env, then install this pipeline plugin. // could we generate a token automatically in "jenkins" plugin? // and put it into .outputs of "jenkins" plugin, // so that user could run "jenkins" and "jenkins-pipeline-kubernetes" in the same tool file.(using depends on). - options.JenkinsToken = os.Getenv("JENKINS_TOKEN") - if options.GitHubToken == "" { - retErrs = append(retErrs, fmt.Errorf("GITHUB_TOKEN is required")) - } - if options.JenkinsToken == "" { - retErrs = append(retErrs, fmt.Errorf("JENKINS_TOKEN is required")) - } + //options.J.Token = os.Getenv("JENKINS_TOKEN") + //if options.J.Token == "" { + // errs = append(errs, fmt.Errorf("env JENKINS_TOKEN is required")) + //} - // TODO(aFlyBird0): check if the jenkins url is valid (try to connect to jenkins) + // read the password from config file(including the outputs from last plugin) first, then from env + if options.J.Password == "" { + options.J.Password = os.Getenv("JENKINS_PASSWORD") + } - return retErrs -} + if options.J.Password == "" { + errs = append(errs, fmt.Errorf("jenkins password is required")) + } -func preHandleOptions(options *Options) { - options.JenkinsURL = strings.Replace(options.JenkinsURL, "http://", "", 1) + return errs } diff --git a/internal/pkg/show/config/plugins/jenkins-pipeline-kubernetes.yaml b/internal/pkg/show/config/plugins/jenkins-pipeline-kubernetes.yaml index df02d82bb..d74ddbbc9 100644 --- a/internal/pkg/show/config/plugins/jenkins-pipeline-kubernetes.yaml +++ b/internal/pkg/show/config/plugins/jenkins-pipeline-kubernetes.yaml @@ -7,8 +7,20 @@ tools: dependsOn: [ ] # options for the plugin options: - # jenkinsUrl, format: hostname:port - jenkinsUrl: localhost:8080 - jenkinsUser: admin + jenkins: + # jenkinsUrl, format: hostname:port, mandatory + url: localhost:8080 + # jenkins user name, default: admin + user: admin + # jenkins password, you have 3 options to set the password: + # 1. use outputs of the `jenkins` plugin, see docs for more details + # 2. set the `JENKINS_PASSWORD` environment variable + # 3. fill in the password in this field(not recommended) + # if all set, devstream will read the password from the config file or outputs from jenkins plugin first, then env var. + password: + # jenkins job name, mandatory + jobName: + # path to the pipeline sript file path, relative to the git repo root directory. default: Jenkinsfile + pipelineScriptPath: Jenkinsfile + # github repo url where the pipeline script is located. mandatory githubRepoUrl: https://github.com/xxx/xxx.git - jenkinsJobName: diff --git a/pkg/util/jenkins/client.go b/pkg/util/jenkins/client.go new file mode 100644 index 000000000..8e34ca2ab --- /dev/null +++ b/pkg/util/jenkins/client.go @@ -0,0 +1,107 @@ +package jenkins + +import ( + "context" + "fmt" + + "github.com/bndr/gojenkins" +) + +const ( + domain = "_" + credentialScope = "GLOBAL" +) + +type ( + // Jenkins is a Jenkins client, + // it is a wrapper around gojenkins.Jenkins and implements additional methods + Jenkins struct { + *gojenkins.Jenkins + + *BasicAuth + JenkinsURL string + } + + BasicAuth struct { + Username string + Password string + } +) + +// NewJenkins creates a new Jenkins client and validates the connection +func NewJenkins(jenkinsURL, username, password string) (*Jenkins, error) { + // validate + if jenkinsURL == "" { + return nil, fmt.Errorf("jenkinsURL is required") + } + if username == "" || password == "" { + return nil, fmt.Errorf("username and password are required") + } + + // create gojenkins client + gojenkinsClient := gojenkins.CreateJenkins(nil, jenkinsURL, username, password) + ctx := context.Background() + + // init and validate client + if _, err := gojenkinsClient.Init(ctx); err != nil { + return nil, fmt.Errorf("failed to init jenkins client: %v", err) + } + + return &Jenkins{ + Jenkins: gojenkinsClient, + BasicAuth: &BasicAuth{ + Username: username, + Password: password, + }, + JenkinsURL: jenkinsURL, + }, nil +} + +func (j *Jenkins) GetCredentialManager() *gojenkins.CredentialsManager { + return &gojenkins.CredentialsManager{ + J: j.Jenkins, + } +} + +func (j *Jenkins) CreateCredentialsUsername(username, password, id, description string) error { + cred := gojenkins.UsernameCredentials{ + ID: id, + Scope: credentialScope, + Username: username, + Password: password, + Description: description, + } + + // create credential + ctx := context.Background() + cm := j.GetCredentialManager() + err := cm.Add(ctx, domain, cred) + if err != nil { + return fmt.Errorf("could not create credential: %v", err) + } + + // get credential to validate creation + getCred := gojenkins.UsernameCredentials{} + if err = cm.GetSingle(ctx, domain, cred.ID, &getCred); err != nil { + return fmt.Errorf("could not get credential: %v", err) + } + + return nil +} + +// GetCredentialsUsername returns the credentials of type username-password with the given id, +// it returns an error if the credential does not exist +func (j *Jenkins) GetCredentialsUsername(id string) (*gojenkins.UsernameCredentials, error) { + getCred := gojenkins.UsernameCredentials{} + ctx := context.Background() + cm := j.GetCredentialManager() + err := cm.GetSingle(ctx, domain, id, &getCred) + if err != nil { + return nil, fmt.Errorf("could not get credential: %v", err) + } + return &getCred, nil +} + +func (j *Jenkins) DeleteCredentialsUsername(id string) error { + return j.GetCredentialManager().Delete(context.Background(), domain, id) +} diff --git a/pkg/util/jenkins/custom.go b/pkg/util/jenkins/custom.go new file mode 100644 index 000000000..b8401ce7a --- /dev/null +++ b/pkg/util/jenkins/custom.go @@ -0,0 +1,59 @@ +package jenkins + +import ( + _ "embed" + "fmt" + + "github.com/parnurzeal/gorequest" + + "github.com/devstream-io/devstream/pkg/util/log" +) + +// this file is used to write custom implementation of jenkins client + +// GetCrumb returns the crumb for jenkins, +// jenkins uses crumb to prevent CSRF(cross-site request forgery), +// ref: https://www.jenkins.io/doc/upgrade-guide/2.176/#upgrading-to-jenkins-lts-2-176-3 +// ref: https://stackoverflow.com/questions/44711696/jenkins-403-no-valid-crumb-was-included-in-the-request +func (j *Jenkins) GetCrumb() (crumbHeaderKey, crumbHeaderValue string, cookie string, err error) { + // crumb response format: + type CrumbResponse struct { + Crumb string `json:"crumb"` + CrumbRequestField string `json:"crumbRequestField"` + } + var crumbResp CrumbResponse + + // get crumb + request := gorequest.New() + getCrumbURL := j.JenkinsURL + `/crumbIssuer/api/json` + resp, body, errs := request.Get(getCrumbURL). + SetBasicAuth(j.BasicAuth.Username, j.BasicAuth.Password). + EndStruct(&crumbResp) + + // check error + if len(errs) != 0 { + return "", "", "", fmt.Errorf("failed to get jenkins crumb: %s", errs) + } + if resp.StatusCode != 200 { + return "", "", "", fmt.Errorf("failed to get jenkins crumb, here is response: %s", body) + } + + log.Debugf("crumb: %+v", crumbResp) + + return crumbResp.CrumbRequestField, crumbResp.Crumb, resp.Header.Get("set-cookie"), nil +} + +// SetCrumb sets the jenkins crumb to the request +func (j *Jenkins) SetCrumb(req *gorequest.SuperAgent) error { + crumbHeaderKey, crumbHeaderValue, cookie, err := j.GetCrumb() + if err != nil { + return err + } + + // all these three should be set in the request, or it will cause 403 + req.Set(crumbHeaderKey, crumbHeaderValue) + req.Set("Cookie", cookie) + req.SetBasicAuth(j.BasicAuth.Username, j.BasicAuth.Password) + + return nil +}