diff --git a/Dockerfile b/Dockerfile index aa1dd5f..1d1b5ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,11 @@ FROM golang:1.10-alpine +LABEL maintainer "James W Holdsworth" +LABEL source "https://github.com/jwholdsworth/jira-cloud-exporter" WORKDIR /go/src/app COPY . . -RUN apk add --update git +RUN apk add --no-cache --update --verbose git RUN go get -d -v ./... RUN go install -v ./... diff --git a/README.md b/README.md index 60e9ac8..d64b87d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # jira-cloud-exporter -Exposes basic JIRA metrics to a Prometheus compatible endpoint. +Exposes basic [JIRA](https://www.atlassian.com/software/jira) metrics (from one or more servers) to a [Prometheus](https://prometheus.io) compatible endpoint using the metric name: *jira_cloud_exporter*. ## Configuration -Configuration is provided in the form of environment variables. +Configuration is provided in the form of environment variables. If multiple Jira servers are being queried, each variable value should be a comma separated list. ### Required @@ -13,7 +13,11 @@ Configuration is provided in the form of environment variables. * `JIRA_URL` is the URL to your organisation's JIRA application. ### Optional -* `JIRA_JQL` is the JIRA query language search filter (defaults to empty, so you'll get everything) + +* `JIRA_JQL` is the JIRA query language search filter (defaults to empty, so you'll get everything). If querying multiple Jira servers this variable is *not* optional. You must delimit null values if not specifying a filter for a server. For instance, if querying two servers: + * Filter for first, no filter for second: `JIRA_JQL="project=test,"` + * No filter for first, filter for second: `JIRA_JQL=",project=test"` + * No filter for either: `JIRA_JQL=","` * `METRICS_PATH` is the endpoint Prometheus should scrape on this exporter. Defaults to `/metrics` * `LISTEN_ADDRESS` is the IP and port to bind this exporter to. Defaults to `:9800`. diff --git a/collector/collector.go b/collector/collector.go index 26dfd52..1905240 100644 --- a/collector/collector.go +++ b/collector/collector.go @@ -5,6 +5,7 @@ import ( "fmt" "io/ioutil" "net/http" + "strings" "time" "github.com/jwholdsworth/jira-cloud-exporter/config" @@ -12,7 +13,7 @@ import ( log "github.com/sirupsen/logrus" ) -// JiraCollector initiates the collection of metrics from the JIRA instance +// JiraCollector initiates the collection of metrics from a JIRA instance func JiraCollector() *JiraMetrics { return &JiraMetrics{ jiraIssues: prometheus.NewDesc(prometheus.BuildFQName("jira", "cloud", "exporter"), @@ -27,10 +28,14 @@ func (collector *JiraMetrics) Describe(ch chan<- *prometheus.Desc) { ch <- collector.jiraIssues } -//Collect implements required collect function for all promehteus collectors +//Collect implements required collect function for all prometheus collectors func (collector *JiraMetrics) Collect(ch chan<- prometheus.Metric) { - collectedIssues := fetchJiraIssues() + collectedIssues, err := fetchJiraIssues() + if err != nil { + log.Error(err) + return + } for _, issue := range collectedIssues.Issues { createdTimestamp := convertToUnixTime(issue.Fields.Created) @@ -49,38 +54,64 @@ func convertToUnixTime(timestamp string) float64 { return float64(dateTime.Unix()) } -func fetchJiraIssues() JiraIssues { - // DI this - cfg := config.Init() - var jiraIssues JiraIssues +func fetchJiraIssues() (JiraIssues, error) { - client := http.Client{} - url := fmt.Sprintf("%s/rest/api/2/search?jql=%s", cfg.JiraURL, cfg.JiraJql) - req, err := http.NewRequest(http.MethodGet, url, nil) + cfgs, err := config.Init() if err != nil { log.Error(err) - return jiraIssues } - req.Header.Set("User-Agent", "jira-cloud-exporter") - req.SetBasicAuth(cfg.JiraUsername, cfg.JiraToken) - log.Info(fmt.Sprintf("Sending request to %s", url)) - res, err := client.Do(req) + var AllIssues JiraIssues - if err != nil { - log.Error(err) - return jiraIssues - } + for _, cfg := range cfgs { + var jiraIssues JiraIssues - body, readErr := ioutil.ReadAll(res.Body) - if readErr != nil { - log.Error(readErr) - return jiraIssues - } + // Confirm the Jira URL begins with the http:// or https:// scheme specification + // Also emit a warning if HTTPS isn't being used + if !strings.HasPrefix(cfg.JiraURL, "http") { + err := fmt.Errorf("The Jira URL: %s does not begin with 'http'", cfg.JiraURL) + return jiraIssues, err + } else if !strings.HasPrefix(cfg.JiraURL, "https://") { + log.Warn("The Jira URL: ", cfg.JiraURL, " is insecure, your API token is being sent in clear text") + } + if len(cfg.JiraUsername) < 6 { + log.Warn("The Jira username has fewer than 6 characters, are you sure it is valid?") + } + if len(cfg.JiraToken) < 10 { + log.Warn("The Jira token has fewer than 10 characters, are you sure it is valid?") + } + + client := http.Client{} + url := fmt.Sprintf("%s/rest/api/2/search?jql=%s", cfg.JiraURL, cfg.JiraJql) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return jiraIssues, err + } + req.Header.Set("User-Agent", "jira-cloud-exporter") + req.SetBasicAuth(cfg.JiraUsername, cfg.JiraToken) + log.Info(fmt.Sprintf("Sending request to %s", url)) + res, err := client.Do(req) + + if err != nil { + return jiraIssues, err + } + + body, readErr := ioutil.ReadAll(res.Body) + if readErr != nil { + return jiraIssues, err + } + + jsonError := json.Unmarshal(body, &jiraIssues) + if jsonError != nil { + return jiraIssues, err + } + + AllIssues.Issues = append(AllIssues.Issues, jiraIssues.Issues...) - jsonError := json.Unmarshal(body, &jiraIssues) - if jsonError != nil { - log.Error(jsonError) } - return jiraIssues + return AllIssues, nil +} + +type error interface { + Error() string } diff --git a/config/config.go b/config/config.go index bdaef62..339b86a 100644 --- a/config/config.go +++ b/config/config.go @@ -1,36 +1,47 @@ package config -import "os" +import ( + "fmt" + "os" + "strings" +) type Config struct { - JiraToken string - JiraUsername string - JiraJql string - JiraURL string - MetricsPath string - ListenAddress string + JiraToken string + JiraUsername string + JiraJql string + JiraURL string } // Init populates the Config struct based on environmental runtime configuration -func Init() Config { +func Init() ([]Config, error) { jiraToken := getEnv("JIRA_TOKEN", "") jiraUsername := getEnv("JIRA_USERNAME", "") jiraJql := getEnv("JIRA_JQL", "") jiraURL := getEnv("JIRA_URL", "") - metricsPath := getEnv("METRICS_PATH", "/metrics") - listenAddress := getEnv("LISTEN_ADDRESS", ":9800") - - appConfig := Config{ - jiraToken, - jiraUsername, - jiraJql, - jiraURL, - metricsPath, - listenAddress, + + tokens := strings.Split(jiraToken, ",") + usernames := strings.Split(jiraUsername, ",") + jqls := strings.Split(jiraJql, ",") + urls := strings.Split(jiraURL, ",") + + appConfig := make([]Config, 0) + + if len(urls) != len(usernames) { + err := fmt.Errorf("The number of Jira URLs doesn't match the number of Jira Usernames") + return appConfig, err + } else if len(usernames) != len(tokens) { + err := fmt.Errorf("The number of Jira Usernames doesn't match the number of Jira Tokens") + return appConfig, err + } + + for i, items := range urls { + temp := Config{tokens[i], usernames[i], jqls[i], items} + appConfig = append(appConfig, temp) } - return appConfig + return appConfig, nil } func getEnv(environmentVariable, defaultValue string) string { diff --git a/main.go b/main.go index 298db7b..6ccc948 100644 --- a/main.go +++ b/main.go @@ -3,24 +3,33 @@ package main import ( "fmt" "net/http" + "os" "github.com/jwholdsworth/jira-cloud-exporter/collector" - "github.com/jwholdsworth/jira-cloud-exporter/config" "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" log "github.com/sirupsen/logrus" ) func main() { - cfg := config.Init() + metricsPath := getEnv("METRICS_PATH", "/metrics") + listenAddress := getEnv("LISTEN_ADDRESS", ":9800") jiraCollector := collector.JiraCollector() prometheus.MustRegister(jiraCollector) - http.Handle(cfg.MetricsPath, promhttp.Handler()) - if cfg.MetricsPath != "/" { - http.Handle("/", http.RedirectHandler(cfg.MetricsPath, http.StatusMovedPermanently)) + http.Handle(metricsPath, promhttp.Handler()) + if metricsPath != "/" { + http.Handle("/", http.RedirectHandler(metricsPath, http.StatusMovedPermanently)) } - log.Info(fmt.Sprintf("Listening on %s", cfg.ListenAddress)) - log.Fatal(http.ListenAndServe(cfg.ListenAddress, nil)) + log.Info(fmt.Sprintf("Listening on %s", listenAddress)) + log.Fatal(http.ListenAndServe(listenAddress, nil)) +} + +func getEnv(environmentVariable, defaultValue string) string { + envVar := os.Getenv(environmentVariable) + if len(envVar) == 0 { + return defaultValue + } + + return envVar }