Skip to content

Commit

Permalink
[OP-1305] Jelease deployment (#2)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* Remove authType variable

Co-authored-by: Kalle Fagerberg <[email protected]>

* remove helm chart and add reference to new location in helm repo

* Updated formatting in README.md

Co-authored-by: Kalle Fagerberg <[email protected]>
  • Loading branch information
2 people authored and GitHub Enterprise committed Sep 16, 2022
1 parent 50ffa91 commit 5df9d38
Show file tree
Hide file tree
Showing 4 changed files with 160 additions and 55 deletions.
8 changes: 4 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -19,4 +19,4 @@ jelease
# vendor/

# Go workspace file
go.work
go.work
25 changes: 25 additions & 0 deletions Earthfile
Original file line number Diff line number Diff line change
@@ -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
52 changes: 38 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
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)
130 changes: 93 additions & 37 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"crypto/tls"
"encoding/json"
"errors"
"fmt"
Expand All @@ -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.
Expand All @@ -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,
},
Expand All @@ -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")
}

Expand All @@ -95,29 +102,39 @@ 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
}

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)
Expand Down Expand Up @@ -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{
Expand All @@ -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
}
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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()
}

0 comments on commit 5df9d38

Please sign in to comment.