From 5df9d388e1a8be682c6c650a66685be5adeabcc2 Mon Sep 17 00:00:00 2001 From: Nicolas Mohr Date: Fri, 16 Sep 2022 09:24:04 +0200 Subject: [PATCH] [OP-1305] Jelease deployment (#2) * Add Earthfile for deps, build and docker image * fix null pointer dereference segfault when no http response is received from jira * Add authtype and skipcertverify config options. Print list of statuses if status is misconfigured. * Add helm chart and instructions in readme. Add timeout for http requests to app * Add dry run support. Make issue descr. configurable. Update README. * Apply suggested chart improvements * Apply suggested README, source and gitignore improvements * Remove superfluous variable * Fix conditional comma formatting Co-authored-by: Kalle Fagerberg * Remove authType variable Co-authored-by: Kalle Fagerberg * remove helm chart and add reference to new location in helm repo * Updated formatting in README.md Co-authored-by: Kalle Fagerberg --- .gitignore | 8 ++-- Earthfile | 25 +++++++++++ README.md | 52 +++++++++++++++------ main.go | 130 ++++++++++++++++++++++++++++++++++++++--------------- 4 files changed, 160 insertions(+), 55 deletions(-) create mode 100644 Earthfile diff --git a/.gitignore b/.gitignore index 7500c9a..b11f110 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ -jelease -.env - +/jelease +/.env* +build/ # from https://github.com/github/gitignore/blob/main/Go.gitignore # Binaries for programs and plugins *.exe @@ -19,4 +19,4 @@ jelease # vendor/ # Go workspace file -go.work \ No newline at end of file +go.work diff --git a/Earthfile b/Earthfile new file mode 100644 index 0000000..193c106 --- /dev/null +++ b/Earthfile @@ -0,0 +1,25 @@ +VERSION 0.6 +FROM golang:1.19.0-bullseye +WORKDIR /jelease + +deps: + COPY go.mod go.sum ./ + RUN go mod download + SAVE ARTIFACT go.mod AS LOCAL go.mod + SAVE ARTIFACT go.sum AS LOCAL go.sum + +build: + FROM +deps + COPY main.go . + RUN go build -o build/jelease main.go + SAVE ARTIFACT build/jelease /jelease AS LOCAL build/jelease + +docker: + FROM ubuntu:22.04 + RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + COPY +build/jelease . + CMD ["/jelease"] + SAVE IMAGE jelease:latest + SAVE IMAGE --push docker-riskident.2rioffice.com/platform/nicolasmohr/jelease diff --git a/README.md b/README.md index 6a53c29..d9b95eb 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,49 @@ # jelease - A newreleases.io ➡️ Jira connector -Automatically create Jira tickets when a newreleases.io release is detected using webhooks. +Automatically create Jira tickets when a newreleases.io release +is detected using webhooks. -## Configuration: +## Configuration The application requires the following environment variables to be set: -- `JELEASE_PORT`: The port the application is expecting traffic on -- `JELEASE_JIRA_URL`: The URL of your Jira instance -- `JELEASE_JIRA_USER`: Jira username to authenticate API requests -- `JELEASE_JIRA_TOKEN`: Jira API token, can also be a password in self-hosted instances -- `JELEASE_PROJECT`: Jira Project key the tickets will be created in -- `JELEASE_ADD_LABELS`: Add additional labels to the created jira ticket -- `JELEASE_DEFAULT_STATUS`: The status the created tickets are supposed to have +- Connection and authentication + + - `JELEASE_AUTH_TYPE`: One of [pat, token]. Determines whether to authenticate using personal access token (on premise) or jira api token (jira cloud) + - `JELEASE_JIRA_TOKEN`: Jira API token, can also be a password in self-hosted instances + - `JELEASE_JIRA_URL`: The URL of your Jira instance + - `JELEASE_JIRA_USER`: Jira username to authenticate API requests + - `JELEASE_PORT`: The port the application is expecting traffic on + - `JELEASE_INSECURE_SKIP_CERT_VERIFY`: Skips verification of Jira server certs when performing http requests. +- Jira ticket creation: + - `JELEASE_ADD_LABELS`: Comma-separated list of labels to add to the created jira ticket + - `JELEASE_DEFAULT_STATUS`: The status the created tickets are supposed to have + - `JELEASE_DRY_RUN`: Don't create tickets, log when a ticket would be created + - `JELEASE_ISSUE_DESCRIPTION`: The description for created issues + - `JELEASE_PROJECT`: Jira Project key the tickets will be created in They can also be specified using a `.env` file in the application directory. -## Building the application +## Local usage -`go build` +1. Populate a `.env` file with configuration values +2. `go run main.go` / `./jelease` +3. Direct newreleases.io webhooks to the `host:port/webhook` route. -## Usage +## Building the application and docker image -1. `go run main.go` / `./jelease` -2. Direct newreleases.io webhooks to the `host:port/webhook` route. \ No newline at end of file +The application uses [earthly](https://earthly.dev/get-earthly) for building +and pushing a docker image. + +After installing earthly, the image can be built by running + +```bash +earthly +docker +# if you want to push a new image version +earhtly --push +docker +``` + +## Deployment + +A helm chart deploying the application together with a webhookrelayd sidecar +is available in the +[platform/helm repo](https://github.2rioffice.com/platform/helm/tree/master/charts/jelease) diff --git a/main.go b/main.go index e314bce..e6d5140 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "crypto/tls" "encoding/json" "errors" "fmt" @@ -25,13 +26,20 @@ var ( // Config contains configuration values from environment and .env file. // Environment takes precedence over the .env file in case of conflicts. type Config struct { - Port string `envconfig:"PORT" default:"8080"` - JiraUrl string `envconfig:"JIRA_URL" required:"true"` - JiraUser string `envconfig:"JIRA_USER" required:"true"` - JiraToken string `envconfig:"JIRA_TOKEN" required:"true"` - Project string `envconfig:"PROJECT" required:"true"` - DefaultStatus string `envconfig:"DEFAULT_STATUS" required:"true"` - AddLabels []string `envconfig:"ADD_LABELS"` + // connection and auth + AuthType string `envconfig:"AUTH_TYPE" required:"true"` + JiraToken string `envconfig:"JIRA_TOKEN" required:"true"` + JiraUrl string `envconfig:"JIRA_URL" required:"true"` + JiraUser string `envconfig:"JIRA_USER" required:"true"` + Port string `envconfig:"PORT" default:"8080"` + SkipCertVerify bool `envconfig:"INSECURE_SKIP_CERT_VERIFY" default:"false"` + + // ticket creation + AddLabels []string `envconfig:"ADD_LABELS"` + DefaultStatus string `envconfig:"DEFAULT_STATUS" required:"true"` + DryRun bool `envconfig:"DRY_RUN" default:"false"` + IssueDescription string `envconfig:"ISSUE_DESCRIPTION" default:"Update issue generated by https://github.2rioffice.com/platform/jelease using newreleases.io"` + Project string `envconfig:"PROJECT" required:"true"` } // Release object unmarshaled from the newreleases.io webhook. @@ -51,7 +59,7 @@ func (r Release) JiraIssue() jira.Issue { labels := append(config.AddLabels, r.Project) return jira.Issue{ Fields: &jira.IssueFields{ - Description: "Update issue generated by https://github.2rioffice.com/platform/jelease using newreleases.io.", + Description: config.IssueDescription, Project: jira.Project{ Key: config.Project, }, @@ -69,7 +77,6 @@ func (r Release) JiraIssue() jira.Issue { // handleGetRoot handles to GET requests for a basic reachability check func handleGetRoot(w http.ResponseWriter, r *http.Request) { - logger.Println("Received health check request") io.WriteString(w, "Ok") } @@ -95,13 +102,16 @@ func handlePostWebhook(w http.ResponseWriter, r *http.Request) { existingIssuesQuery := fmt.Sprintf("status = %q and labels = %q", config.DefaultStatus, release.Project) existingIssues, resp, err := jiraClient.Issue.Search(existingIssuesQuery, &jira.SearchOptions{}) if err != nil { - body, readErr := io.ReadAll(resp.Body) errCtx := errors.New("error response from Jira when searching previous issues") - if readErr != nil { - logger.Println(fmt.Errorf("%v: %w. Failed to decode response body: %v", errCtx, err, readErr).Error()) - } else { - logger.Println(fmt.Errorf("%v: %w. Response body: %v", errCtx, err, string(body)).Error()) + if resp != nil { + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + logger.Println(fmt.Errorf("%v: %w. Failed to decode response body: %v", errCtx, err, readErr).Error()) + } else { + logger.Println(fmt.Errorf("%v: %w. Response body: %v", errCtx, err, string(body)).Error()) + } } + logger.Println(fmt.Errorf("%v: %w", errCtx, err)) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -109,15 +119,22 @@ func handlePostWebhook(w http.ResponseWriter, r *http.Request) { if len(existingIssues) == 0 { // no previous issues, create new jira issue i := release.JiraIssue() + if config.DryRun { + logger.Printf("skipping creation of issue because Config.DryRun is enabled. Would've created issue: %v", i.Fields.Summary) + return + } newIssue, response, err := jiraClient.Issue.Create(&i) if err != nil { - body, readErr := io.ReadAll(response.Body) errCtx := errors.New("error response from Jira when creating issue") - if readErr != nil { - logger.Printf("%v: %v. Failed to decode response body: %v", errCtx, err, readErr) - } else { - logger.Printf("%v: %v. Response body: %v", errCtx, err, string(body)) + if resp != nil { + body, readErr := io.ReadAll(response.Body) + if readErr != nil { + logger.Printf("%v: %v. Failed to decode response body: %v", errCtx, err, readErr) + } else { + logger.Printf("%v: %v. Response body: %v", errCtx, err, string(body)) + } } + logger.Println(fmt.Errorf("%v: %w", errCtx, err)) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } logger.Printf("Created issue %v\n", newIssue.ID) @@ -156,6 +173,10 @@ func handlePostWebhook(w http.ResponseWriter, r *http.Request) { Summary []summaryUpdate `json:"summary" structs:"summary"` } previousSummary := oldestExistingIssue.Fields.Summary + if config.DryRun { + logger.Printf("skipping update of issue because Config.DryRun is enabled. Would've updated issue %v with new summary %q", oldestExistingIssue.Key, release.IssueSummary()) + return + } updates := map[string]any{ "update": issueUpdate{ Summary: []summaryUpdate{ @@ -165,13 +186,16 @@ func handlePostWebhook(w http.ResponseWriter, r *http.Request) { } resp, err = jiraClient.Issue.UpdateIssue(oldestExistingIssue.ID, updates) if err != nil { - body, readErr := io.ReadAll(resp.Body) errCtx := errors.New("error response from Jira when updating issue") - if readErr != nil { - logger.Printf("%v: %v. Failed to decode response body: %v", errCtx, err, readErr) - } else { - logger.Printf("%v: %v. Response body: %v", errCtx, err, string(body)) + if resp != nil { + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + logger.Printf("%v: %v. Failed to decode response body: %v", errCtx, err, readErr) + } else { + logger.Printf("%v: %v. Response body: %v", errCtx, err, string(body)) + } } + logger.Println(fmt.Errorf("%v: %w", errCtx, err)) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -190,26 +214,46 @@ func configSetup() error { } logger.Printf("Jira URL: %v\n", config.JiraUrl) - tp := jira.BasicAuthTransport{ - Username: config.JiraUser, - Password: config.JiraToken, + + var httpClient *http.Client + tlsConfig := tls.Config{InsecureSkipVerify: config.SkipCertVerify} + + switch strings.ToLower(config.AuthType) { + case "pat": + httpClient = (&jira.PATAuthTransport{ + Token: config.JiraToken, + Transport: &http.Transport{TLSClientConfig: &tlsConfig}, + }).Client() + case "token": + httpClient = (&jira.BasicAuthTransport{ + Username: config.JiraUser, + Password: config.JiraToken, + Transport: &http.Transport{TLSClientConfig: &tlsConfig}, + }).Client() + default: + return fmt.Errorf("invalid AUTH_TYPE value %q. Has to be one of [pat, token]", config.AuthType) } - jiraClient, err = jira.NewClient(tp.Client(), config.JiraUrl) + httpClient.Timeout = 10 * time.Second + jiraClient, err = jira.NewClient(httpClient, config.JiraUrl) if err != nil { return fmt.Errorf("failed to create jira client: %w", err) } + return nil } func projectExists() error { allProjects, response, err := jiraClient.Project.GetList() if err != nil { - body, readErr := io.ReadAll(response.Body) errCtx := errors.New("error response from Jira when retrieving project list") - if readErr != nil { - return fmt.Errorf("%v: %w. Failed to decode response body: %v", errCtx, err, readErr) + if response != nil { + body, readErr := io.ReadAll(response.Body) + if readErr != nil { + return fmt.Errorf("%v: %w. Failed to decode response body: %v", errCtx, err, readErr) + } + return fmt.Errorf("%v: %w. Response body: %v", errCtx, err, string(body)) } - return fmt.Errorf("%v: %w. Response body: %v", errCtx, err, string(body)) + return fmt.Errorf("%v: %w", errCtx, err) } var projectExists bool for _, project := range *allProjects { @@ -227,12 +271,15 @@ func projectExists() error { func statusExists() error { allStatuses, response, err := jiraClient.Status.GetAllStatuses() if err != nil { - body, readErr := io.ReadAll(response.Body) errCtx := errors.New("error response from Jira when retrieving status list: %+v") - if readErr != nil { - return fmt.Errorf("%v: %w. Failed to decode response body: %v", errCtx, err, readErr) + if response != nil { + body, readErr := io.ReadAll(response.Body) + if readErr != nil { + return fmt.Errorf("%v: %w. Failed to decode response body: %v", errCtx, err, readErr) + } + return fmt.Errorf("%v: %w. Response body: %v", errCtx, err, string(body)) } - return fmt.Errorf("%v: %w. Response body: %v", errCtx, err, string(body)) + return fmt.Errorf("%v: %w", errCtx, err) } var statusExists bool for _, status := range allStatuses { @@ -242,7 +289,14 @@ func statusExists() error { } } if !statusExists { - return fmt.Errorf("status %v does not exist on your Jira server", config.DefaultStatus) + var statusSB strings.Builder + for i, status := range allStatuses { + if i > 0 { + statusSB.WriteString(", ") + } + statusSB.WriteString(status.Name) + } + return fmt.Errorf("status %q does not exist on your Jira server for project %q. Available statuses: [%v]", config.DefaultStatus, config.Project, statusSB.String()) } return nil } @@ -278,9 +332,11 @@ func run() error { if err != nil { return fmt.Errorf("error in check if configured project exists: %w", err) } + logger.Printf("Configured project %q found ✓\n", config.Project) err = statusExists() if err != nil { return fmt.Errorf("error in check if configured default status exists: %w", err) } + logger.Printf("Configured default status %q found ✓\n", config.DefaultStatus) return serveHTTP() }