perri.to: Un méli-mélo de choses

Créer des plugins JIRA cloud (atlassian connect) en Go

  2020-11-09


Qu’est-ce qu’un plug-in pour JIRA cloud

Atlassian accepte les plugins Cloud sous la forme d’applications du framework Atlassian Connect, qui consistent en une série d’endpoints servant divers iframes affichés dans leur interface, ainsi que quelques webhooks. Toutes les capacités d’un plug-in sont déclarées dans un fichier JSON utilisé par JIRA ou leurs autres apps pour « installer » le plugin.

Comme il s’agit principalement de servir du contenu, si notre infrastructure est déjà composée de services en Go, nous voudrons naturellement ajouter le plug-in à cette même infrastructure existante.

Chez ShiftLeft, nous avons récemment rencontré ce besoin particulier et créé un framework en Go pour cette tâche comme décrit dans cet article.

Il s’avère qu’avec toutes les pièces en place, en créer un est plus facile qu’on ne le penserait.

Voici un exemple commenté d’un plugin très basique (il est fonctionnel, il vous suffit d’une URL publique qui le sert en SSL sur le port 443).

Un exemple d’implémentation

D’abord, les imports obligatoires, que tout le monde exclut de ses articles mais que je trouve bien plus faciles à lire lorsqu’ils sont présents.

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"
)

Ensuite, nous allons définir un storage ; celui-ci est un jouet qui lit et écrit dans des fichiers sur le FS, et il ne faut jamais l’utiliser en 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
}

Le tout se fait de manière plutôt déclarative dans la fonction main (appelée real main pour qu’elle puisse retourner une erreur et être gérée depuis le vrai main).


func realMain() error {

Nous allons récupérer toutes nos valeurs depuis l’environnement, ainsi cela fonctionne comme un exemple plus générique.


	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")

La toute première étape consiste à instancier un handling.Plugin qui va définir les fonctionnalités de base de notre 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

L’ordre dans lequel nous ajoutons les handlers n’a pas particulièrement d’importance, mais je vais le faire dans un ordre qui, selon moi, suit la progression dans laquelle ils pourraient être utilisés. Reportez-vous au README du framework pour des liens détaillés vers la documentation d’atlassian sur les particularités des valeurs acceptées ; pour les plus courantes, nous avons inclus des constantes/structures Go.

Le premier handler à ajouter est un handler pour l’événement installed, qui recevra un POST de JIRA lors de l’installation de notre plugin ; il existe d’autres événements de cycle de vie tels que uninstalled ou 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

Remarquez que nous passons un type spécial de func, dérivé de http.HandlerFunc, qui accepte un handle vers le storage.Store et vers les informations d’installation de jira, s’il y en a (il n’y en aura probablement pas dans le cas d’une installation, mais il y en aura pour le reste des événements).


		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")

Ensuite, nous ajoutons un champ à un issue ; ceux-ci ne sont généralement ni éditables ni affichés dans aucun des écrans, à moins que vous ne les ajoutiez programmatiquement aux écrans/onglets. Ils servent couramment à stocker des informations tierces.

Ils peuvent être ajoutés par code, mais l’idéal serait de les définir ici puis de les configurer par 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)
	}

Ensuite, nous ajoutons un Webhook ; pour cet exemple, nous allons utiliser jira:issue_updated, qui invoquera cet endpoint chaque fois que quoi que ce soit change dans un issue. Notez que nous pouvons passer un path enveloppé dans un handling.RoutePath car nous devons spécifier quelles clés de request.Form nous pouvons recevoir ; reportez-vous au README pour les liens vers les différentes options de clés définies par JIRA.

Le hook reçoit un appel à chaque changement ; il n’est pas possible de recevoir d’informations sur l’événement lui-même au-delà des informations sur le projet et l’élément affecté ; on déclencherait typiquement une requête vers JIRA pour développer ces infos à l’aide des données envoyées.


	// 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")

Enfin, nous allons ajouter un webPanel ; ce sont des panneaux situés à divers endroits de l’interface de JIRA qui seront affichés dans le cadre de leur UI, mais dont le contenu est récupéré depuis l’URL passée. Le conteneur (premier paramètre) n’a pas nécessairement besoin d’être webPanel, il existe plusieurs valeurs acceptées pour différents types d’affichages tels que jiraProjectAdminTabPanels, qui placera une section dans la page d’administration du projet pouvant servir à modifier nos propres réglages au sein de l’UI de JIRA).

Veuillez lire la prochaine section concernant les exigences relatives au contenu de ces panneaux.


	// 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)
	}

Une fois que nous avons défini toutes les sections nécessaires à ajouter, nous invoquons plugin.Router, qui accepte un *gorilla.Mux optionnel et le retourne (ou en crée un nouveau) avec un sous-routeur pour tous les paths du plugin, y compris le service du fichier atlassian-connect.json.


	router := plugin.Router(nil)

Enfin, comme les panneaux ne sont pas générés automatiquement en raison de leur nature complexe, nous pouvons ajouter des listeners pour les différentes URLs de panneau que nous avons définies. Le contenu des panneaux n’est que du HTML basique ; la seule note importante est que <script src="https://connect-cdn.atl-paas.net/all.js" type="text/javascript" rel="preload"></script> doit être inclus, car il fournit un moyen pour le contenu chargé de communiquer avec l’UI existante dans jira (il utilise des iframes, je crois).


	// 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)
	}
}

Un runner simple

Je lance ceci avec le script suivant.

#!/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

Ressources

Le main.go complet

Le script d’appel