perri.to: A mashup of things

Creating JIRA cloud (atlassian connect) plugins in Go

  2020-11-09


What is a plug-in for JIRA cloud

Atlassian accepts Cloud plugin in the form of Atlassian Connect Framework applications, which consist on a series of endpoints that serve various iframes populated in their UI along with some webhooks. All the capabilities of a plug-in are declared in a JSON file used by JIRA or their other apps to “install” the plugin.

Since it is mainly serving content, if our infrastructure already consists of Go services we will naturally want to add the plug-in to that same existing infrastructure.

We, at ShiftLeft recently found this particular requirement and created a Go Framework for said task
as described in this post.

Turns out that with all the pieces in place, it is easier than one would thing to create one of these.

Here is a commented sample of a very basic plugin (this is functional, you just need a public URL that serves it over SSL on the port 443)

A sample implementation

First, the mandatory imports, which everybody excludes from their posts but I find so much easier to read the post if you have them

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
	"path"
	"strconv"

	"github.com/ShiftLeftSecurity/atlassian-connect-go/handling"
	"github.com/ShiftLeftSecurity/atlassian-connect-go/storage"
	"github.com/gorilla/mux"
)

Then, we will define a storage, this one is a toy one that reads and writes from files in the FS, one should never use it in production


// Storage, this is a barebones and really bad implementation of our required storage.
type cheapAndNasstyStorage struct {
}

const storagePath = "cheapstorage"

func (c *cheapAndNasstyStorage) SaveJiraInstallInformation(j *storage.JiraInstallInformation) error {
	if err := os.MkdirAll(storagePath, os.ModePerm); err != nil {
		return fmt.Errorf("creating data storage (aka, the folder): %w", err)
	}
	// in real world this should be validated
	f, err := os.OpenFile(path.Join(storagePath, j.ClientKey), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.ModePerm)
	if err != nil {
		return fmt.Errorf("opening file for storage: %w", err)
	}
	defer f.Close()
	if err := json.NewEncoder(f).Encode(j); err != nil {
		return fmt.Errorf("writing jira install information: %w", err)
	}
	return nil
}

func (c *cheapAndNasstyStorage) JiraInstallInformation(clientKey string) (*storage.JiraInstallInformation, error) {
	p := path.Join(storagePath, clientKey)
	if _, err := os.Stat(p); err != nil {
		return nil, nil
	}
	f, err := os.Open(p)
	if err != nil {
		return nil, fmt.Errorf("opening client data file: %w", err)
	}
	var jii storage.JiraInstallInformation
	if err := json.NewDecoder(f).Decode(&jii); err != nil {
		return nil, fmt.Errorf("decoding jira install info: %w", err)
	}
	return &jii, nil
}

The whole thing is done in a rather declarative way in the main function (called real main so it can return an error and be handled from the actual main)


func realMain() error {

We will be getting all of our values from the env, so this works as a more generic sample.


	st := &cheapAndNasstyStorage{}
	logger := log.New(os.Stdout, "JIRALTALK:", log.Ldate|log.Ltime|log.Lshortfile)
	pluginURL := os.Getenv("PLUGIN_URL")
	pluginKey := os.Getenv("PLUGIN_KEY")
	pluginName := os.Getenv("PLUGIN_NAME")
	pluginDescription := os.Getenv("PLUGIN_DESCRIPTION")

	vendorName := os.Getenv("VENDOR_NAME")
	vendorURL := os.Getenv("VENDOR_URL")

The very first step is instantiating a handling.Plugin which will define the base features of our plugin


	// Instantiate a plugin with the necessary data for a simple version.
	plugin := handling.NewPlugin(pluginName,// The human readable name
		pluginDescription, // The human readable description
		pluginKey, // A unique (as in the whole world or at least server) key
		pluginURL, // The URL where we will host this plugin
        "",  // the relative path where this plugin endpoints are served (ie if you serve this under a subpath of your API)
		st, // our storage
        logger, // a default go logger.
		[]string{
			"READ",
			"WRITE",
			"ACT_AS_USER",
			"ADMIN"}, // The scopes/permissions we want from JIRA (users will get prompted for these)
		handling.Vendor{
			Name: vendorName,
			URL:  vendorURL,
		}) // Information about US or who we do this plugin in behalf of

The order in which we add handlers is not particularly important but ill do it in an order that I find follows the progression in which they might be used, please go to the README of the framework for detailed links to atlassian docs on the particulars of accepted values, for the most common ones we included Go constants/structures.

The first handler to add, is one for the installed event which will receive a POST from JIRA upon installation of our plugins, there are oter lifecycle events such as uninstalled or disabled.


	// Add a handler for the Install event
	err := plugin.AddLifecycleEvent(handling.LCInstalled, "/installed",
		// jira install information is not filled as this is the first call the plugin ever gets

Notice we pass a special kind of func, derived from http.HandlerFunc which acceptsa handle to the storage.Store and to the jira install information if any (most likely there will not be any for the case of an install but there will be for the rest of the events)


		func(_ *storage.JiraInstallInformation, st storage.Store, w http.ResponseWriter, r *http.Request) {
			var jii storage.JiraInstallInformation
			if err := json.NewDecoder(r.Body).Decode(&jii); err != nil {
				logger.Printf("decoding jira install information from body: %v", err)
				w.WriteHeader(http.StatusBadRequest)
				return
			}
			logger.Printf("called installed for client: %s", jii.ClientKey)
			// you could check if this is a returning client, for instance.
			if err := st.SaveJiraInstallInformation(&jii); err != nil {
				logger.Printf("something went wrong installing: %v", err)
				w.WriteHeader(http.StatusInternalServerError)
				return
			}
			w.WriteHeader(http.StatusOK)
		})
	if err != nil {
		return fmt.Errorf("adding lifecycle event: %w", err)
	}

	issueFieldKey := os.Getenv("ISSUE_FIELD_KEY")
	issueFieldDescription := os.Getenv("ISSUE_FIELD_DESCRIPTION")
	issueFieldName := os.Getenv("ISSUE_FIELD_NAME")

The next we do is adding a field to an issue, hese typically are not editable nor show in any of the screens unless you programatically add them to screens/tabs. They are commonly used to store 3rd party info.

They can be added through code, but ideally one would define them here and then configure them in code.


	err = plugin.AddJiraIssueField(handling.JiraIssueFields{
		Description: handling.Description{
			Value: issueFieldDescription,
		},
		Key: issueFieldKey,
		Name: handling.Name{
			Value: issueFieldName,
		},
		Type: "string",
	})
	if err != nil {
		return fmt.Errorf("adding jira issue field: %w", err)
	}

Next, we add a Webhook, for this example we will use jira:issue_updated which will invoke this endpoint every time something at all changes in an issue. Please notice that we can pass a path that is wrapped in a handling.RoutePath because we need to specify which request.Form keys we can receive, refer to the README for links to the various options for JIRA defined keys.

The hook receives a hit with every change, ther is not a possibility to receive information about the event itself other than project and item affected information, one would typically trigger a request to JIRA to expand this info using the sent data.


	// Add a Web hook, this cofigures jira to call the passed handler when a given event is triggered
	err = plugin.AddWebhook("jira:issue_updated",
		handling.NewRoutePath("/api/issue_updated",
			map[string]string{
				"id":         "{issue.key}",
				"projectKey": "{project.key}"}),
		func(jii *storage.JiraInstallInformation, st storage.Store, w http.ResponseWriter, r *http.Request) {
			// Notice JiraInstallInfo is already provided
			q := r.URL.Query()
			issueID := q.Get("id")
			projectKey := q.Get("projectKey")
			logger.Printf("Issue KEY %s of Project %s changed somehow", issueID, projectKey)
		})
	if err != nil {
		return fmt.Errorf("adding jira webhook: %w", err)
	}

	webPanelKey := os.Getenv("WEB_PANEL_KEY")
	webPanelName := os.Getenv("WEB_PANEL_NAME")
	webPanelURL := os.Getenv("WEB_PANEL_URL")
	webPanelHandlerURL := os.Getenv("WEB_PANEL_HANDLER_URL")

Finally we will add a webPanel these are panels located in various parts of the JIRA UI which will be displayed as part of their UI but the content fetched from the passed URL. The container (first parameter, doesn’t necesarily need to be webPanel there are various accepted values for different kinds of displays such as jiraProjectAdminTabPanels which will place a section in the project admin page which can be used to edit our own settings as part of JIRA UI)

Please read the next section about requirements for the content of these panels.


	// Add a Panel, this will add a panel in the right of the default issue view which will display
	// the contents of the webPanelURL
	err = plugin.AddWebPanel("webPanels", handling.WebPanel{
		Conditions: []handling.Conditions{{Condition: "user_is_logged_in"}},
		Key:        webPanelKey,
		Location:   "atl.jira.view.issue.right.context",
		Name: handling.Name{
			Value: webPanelName,
		},
		URL:    webPanelURL,
		Weight: 100,
	})
	if err != nil {
		return fmt.Errorf("adding jira web panel: %w", err)
	}

Once we have defined all necessary sections to be added, we invoke plugin.Router which accepts an optional *gorilla.Mux and returns it (or a new one) with a subrouter for all the plugin paths, including the serving of the atlassian-connect.json file


	router := plugin.Router(nil)

Finally, since panels are not automatically generated due their complex nature, we can add listeners for the various panel URLs we defined. Panel contents are just basic HTML, the only important note is that <script src="https://connect-cdn.atl-paas.net/all.js" type="text/javascript" rel="preload"></script> must be included as it provides a way for the loaded content to talk to the existing UI in jira (it uses iframes I think)


	// Add an extra handler, which will respond to the panel request and its response rendered in
	// the jira section indicated by the pannel
	router.Methods(http.MethodGet).Path(webPanelHandlerURL).HandlerFunc(
		plugin.VerifiedHandleFunc(func(jii *storage.JiraInstallInformation, st storage.Store, w http.ResponseWriter, r *http.Request) {
			logger.Printf("asked for field page")
			if jii == nil {
				logger.Println("received unauthenticated request request")
				w.WriteHeader(http.StatusUnauthorized)
				return
			}
			vars := mux.Vars(r)
			issueID := vars["issueID"]
			projectKey := vars["projectKey"]
			/// BEWARE, the 	<script src="https://connect-cdn.atl-paas.net/all.js" type="text/javascript" rel="preload"></script>
			// part is necessary or this will not work.
			returnText := fmt.Sprintf(`<!doctype html>
<html>

<head>
	<meta charset="utf-8">
	<meta http-equiv="X-UA-Compatible" content="IE=edge">
	<meta name="ap-local-base-url" content="%s">
    <title>Demo Info</title>
</head>

<body class="aui-page-hybrid">
	<section role="main">
		<div class="sl-flow-column">
			<div class="sl-flow-60">
				<ul>
					<li>Issue ID: %s</li>
					<li>Project Key: %s</li>
				</ul>
			</div>
		</div>
	</section>
	<script src="https://connect-cdn.atl-paas.net/all.js" type="text/javascript" rel="preload"></script>
</body>

</html>`, pluginURL, issueID, projectKey)
			w.Header().Add("content-length", strconv.Itoa(len(returnText)))
			w.Header().Add("content-type", "text/html")
			w.Write([]byte(returnText))
		}))

	router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error {
		pathTmpl, _ := route.GetPathTemplate()
		methods, _ := route.GetMethods()

		logger.Printf("Loaded Routes: %s %s", methods, pathTmpl)

		return nil
	})

	return http.ListenAndServe(":9876", router)
}
func main() {
	if err := realMain(); err != nil {
		os.Exit(1)
	}
}

A simple runner

I run this with the following Script

#!/bin/bash
export PLUGIN_URL="https://public.url.with.ssl"
export PLUGIN_KEY="unique.key.for.our.product"
export PLUGIN_NAME="Demo JIRA plugin"
export PLUGIN_DESCRIPTION="A plugin for a demo blog post"

export VENDOR_NAME="Horacio Duran"
export VENDOR_URL="https://perri.to"

export ISSUE_FIELD_DESCRIPTION="Horacio's Demo Content"
export ISSUE_FIELD_KEY="horacio-demo-content-field"
export ISSUE_FIELD_NAME="Horacio Content"

export WEB_PANEL_KEY="horacio-demo-info"
export WEB_PANEL_NAME="Horacio Demo Info"
export WEB_PANEL_URL="pages/horaciofield/{issue.id}/{project.key}"
export WEB_PANEL_HANDLER_URL="/pages/horaciofield/{issueID}/{projectKey}"

go build .
./jirademo

Materials

The full main.go

The calling script

comments powered by Disqus