Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ Application Options:
--scrape.time.query= Scrape time for query results (time.duration) [$SCRAPE_TIME_QUERY]
--scrape.time.live= Scrape time for live metrics (time.duration) (default: 30s) [$SCRAPE_TIME_LIVE]
--stats.summary.maxage= Stats Summary metrics max age (time.duration) [$STATS_SUMMARY_MAX_AGE]
--azuredevops.url= Azure DevOps url (empty if hosted by microsoft) [$AZURE_DEVOPS_URL]
--azuredevops.url= Azure DevOps URL (empty if hosted by Microsoft) [$AZURE_DEVOPS_URL]
--azuredevops.access-token= Azure DevOps access token [$AZURE_DEVOPS_ACCESS_TOKEN]
--azuredevops.access-token-file= Azure DevOps access token (from file) [$AZURE_DEVOPS_ACCESS_TOKEN_FILE]
--azuredevops.tenant-id= Azure tenant ID for Service Principal authentication [$AZURE_TENANT_ID]
--azuredevops.client-id= Client ID for Service Principal authentication [$AZURE_CLIENT_ID]
--azuredevops.client-secret= Client secret for Service Principal authentication [$AZURE_CLIENT_SECRET]
--azuredevops.organisation= Azure DevOps organization [$AZURE_DEVOPS_ORGANISATION]
--azuredevops.apiversion= Azure DevOps API version (default: 5.1) [$AZURE_DEVOPS_APIVERSION]
--azuredevops.agentpool= Enable scrape metrics for agent pool (IDs) [$AZURE_DEVOPS_AGENTPOOL]
Expand Down
199 changes: 167 additions & 32 deletions azure-devops-client/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package AzureDevopsClient

import (
"encoding/json"
"errors"
"fmt"
"net/url"
Expand All @@ -11,17 +12,30 @@ import (

resty "github.com/go-resty/resty/v2"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/zap"
)

type AzureDevopsClient struct {
logger *zap.SugaredLogger

// RequestCount has to be the first words
// in order to be 64-aligned on 32-bit architectures.
RequestCount uint64
RequestRetries int

organization *string
collection *string
accessToken *string

// we can either use a PAT token for authentication ...
accessToken *string

// ... or client id and secret
tenantId *string
clientId *string
clientSecret *string

entraIdToken *EntraIdToken
entraIdTokenLastRefreshed int64

HostUrl *string

Expand All @@ -48,8 +62,22 @@ type AzureDevopsClient struct {
}
}

func NewAzureDevopsClient() *AzureDevopsClient {
c := AzureDevopsClient{}
type EntraIdToken struct {
TokenType *string `json:"token_type"`
ExpiresIn *int64 `json:"expires_in"`
ExtExpiresIn *int64 `json:"ext_expires_in"`
AccessToken *string `json:"access_token"`
}

type EntraIdErrorResponse struct {
Error *string `json:"error"`
ErrorDescription *string `json:"error_description"`
}

func NewAzureDevopsClient(logger *zap.SugaredLogger) *AzureDevopsClient {
c := AzureDevopsClient{
logger: logger,
}
c.Init()

return &c
Expand All @@ -62,6 +90,8 @@ func (c *AzureDevopsClient) Init() {
c.SetRetries(3)
c.SetConcurrency(10)

c.entraIdTokenLastRefreshed = 0

c.LimitBuildsPerProject = 100
c.LimitBuildsPerDefinition = 10
c.LimitReleasesPerDefinition = 100
Expand Down Expand Up @@ -115,46 +145,151 @@ func (c *AzureDevopsClient) SetAccessToken(token string) {
c.accessToken = &token
}

func (c *AzureDevopsClient) SetTenantId(tenantId string) {
c.tenantId = &tenantId
}

func (c *AzureDevopsClient) SetClientId(clientId string) {
c.clientId = &clientId
}

func (c *AzureDevopsClient) SetClientSecret(clientSecret string) {
c.clientSecret = &clientSecret
}

func (c *AzureDevopsClient) SupportsPatAuthentication() bool {
return c.accessToken != nil && len(*c.accessToken) > 0
}

func (c *AzureDevopsClient) SupportsServicePrincipalAuthentication() bool {
return c.tenantId != nil && len(*c.tenantId) > 0 &&
c.clientId != nil && len(*c.clientId) > 0 &&
c.clientSecret != nil && len(*c.clientSecret) > 0
}

func (c *AzureDevopsClient) HasExpiredEntraIdAccessToken() bool {
var currentUnix = time.Now().Unix()

// subtract 60 seconds of offset (should be enough time to use fire all requests)
return (c.entraIdToken == nil || currentUnix >= c.entraIdTokenLastRefreshed+*c.entraIdToken.ExpiresIn-60)
}

func (c *AzureDevopsClient) RefreshEntraIdAccessToken() (string, error) {
var restClient = resty.New()

restClient.SetBaseURL(fmt.Sprintf("https://login.microsoftonline.com/%v/oauth2/v2.0/token", *c.tenantId))

restClient.SetFormData(map[string]string{
"client_id": *c.clientId,
"client_secret": *c.clientSecret,
"grant_type": "client_credentials",
"scope": "499b84ac-1321-427f-aa17-267ca6975798/.default", // the scope is always the same for Azure DevOps
})

restClient.SetHeader("Content-Type", "application/x-www-form-urlencoded")
restClient.SetHeader("Accept", "application/json")
restClient.SetRetryCount(c.RequestRetries)

var response, err = restClient.R().Post("")

if err != nil {
return "", err
}

var responseBody = response.Body()

var errorResponse *EntraIdErrorResponse

err = json.Unmarshal(responseBody, &errorResponse)

if err != nil {
return "", err
}

if errorResponse.Error != nil && len(*errorResponse.Error) > 0 {
return "", fmt.Errorf("could not request a token, error: %v %v", *errorResponse.Error, *errorResponse.ErrorDescription)
}

err = json.Unmarshal(responseBody, &c.entraIdToken)

if err != nil {
return "", err
}

if c.entraIdToken == nil || c.entraIdToken.AccessToken == nil {
return "", errors.New("could not request an access token")
}

c.entraIdTokenLastRefreshed = time.Now().Unix()

return *c.entraIdToken.AccessToken, nil
}

func (c *AzureDevopsClient) rest() *resty.Client {
var client, err = c.restWithAuthentication("dev.azure.com")

if err != nil {
c.logger.Fatalf("could not create a rest client: %v", err)
}

return client
}

func (c *AzureDevopsClient) restVsrm() *resty.Client {
var client, err = c.restWithAuthentication("vsrm.dev.azure.com")

if err != nil {
c.logger.Fatalf("could not create a rest client: %v", err)
}

return client
}

func (c *AzureDevopsClient) restWithAuthentication(domain string) (*resty.Client, error) {
if c.restClient == nil {
c.restClient = resty.New()
if c.HostUrl != nil {
c.restClient.SetBaseURL(*c.HostUrl + "/" + *c.organization + "/")
} else {
c.restClient.SetBaseURL(fmt.Sprintf("https://dev.azure.com/%v/", *c.organization))
}
c.restClient.SetHeader("Accept", "application/json")
c.restClient = c.restWithoutToken(domain)
}

if c.SupportsPatAuthentication() {
c.restClient.SetBasicAuth("", *c.accessToken)
c.restClient.SetRetryCount(c.RequestRetries)
if c.delayUntil != nil {
c.restClient.OnBeforeRequest(c.restOnBeforeRequestDelay)
} else {
c.restClient.OnBeforeRequest(c.restOnBeforeRequest)
}
} else if c.SupportsServicePrincipalAuthentication() {
if c.HasExpiredEntraIdAccessToken() {
var accessToken, err = c.RefreshEntraIdAccessToken()

c.restClient.OnAfterResponse(c.restOnAfterResponse)
if err != nil {
return nil, err
}

c.restClient.SetBasicAuth("", accessToken)
}
} else {
return nil, errors.New("no valid authentication method provided")
}

return c.restClient
return c.restClient, nil
}

func (c *AzureDevopsClient) restVsrm() *resty.Client {
if c.restClientVsrm == nil {
c.restClientVsrm = resty.New()
if c.HostUrl != nil {
c.restClientVsrm.SetBaseURL(*c.HostUrl + "/" + *c.organization + "/")
} else {
c.restClientVsrm.SetBaseURL(fmt.Sprintf("https://vsrm.dev.azure.com/%v/", *c.organization))
}
c.restClientVsrm.SetHeader("Accept", "application/json")
c.restClientVsrm.SetBasicAuth("", *c.accessToken)
c.restClientVsrm.SetRetryCount(c.RequestRetries)
c.restClientVsrm.OnBeforeRequest(c.restOnBeforeRequest)
c.restClientVsrm.OnAfterResponse(c.restOnAfterResponse)
func (c *AzureDevopsClient) restWithoutToken(domain string) *resty.Client {
var restClient = resty.New()

if c.HostUrl != nil {
restClient.SetBaseURL(*c.HostUrl + "/" + *c.organization + "/")
} else {
restClient.SetBaseURL(fmt.Sprintf("https://%v/%v/", domain, *c.organization))
}

restClient.SetHeader("Accept", "application/json")
restClient.SetRetryCount(c.RequestRetries)

if c.delayUntil != nil {
restClient.OnBeforeRequest(c.restOnBeforeRequestDelay)
} else {
restClient.OnBeforeRequest(c.restOnBeforeRequest)
}

return c.restClientVsrm
restClient.OnAfterResponse(c.restOnAfterResponse)

return restClient
}

func (c *AzureDevopsClient) concurrencyLock() {
Expand Down
5 changes: 4 additions & 1 deletion config/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,12 @@ type (

// azure settings
AzureDevops struct {
Url *string `long:"azuredevops.url" env:"AZURE_DEVOPS_URL" description:"Azure DevOps url (empty if hosted by microsoft)"`
Url *string `long:"azuredevops.url" env:"AZURE_DEVOPS_URL" description:"Azure DevOps URL (empty if hosted by Microsoft)"`
AccessToken string `long:"azuredevops.access-token" env:"AZURE_DEVOPS_ACCESS_TOKEN" description:"Azure DevOps access token" json:"-"`
AccessTokenFile *string `long:"azuredevops.access-token-file" env:"AZURE_DEVOPS_ACCESS_TOKEN_FILE" description:"Azure DevOps access token (from file)"`
TenantId string `long:"azuredevops.tenant-id" env:"AZURE_TENANT_ID" description:"Azure tenant ID for Service Principal authentication" json:"-"`
ClientId string `long:"azuredevops.client-id" env:"AZURE_CLIENT_ID" description:"Client ID for Service Principal authentication" json:"-"`
ClientSecret string `long:"azuredevops.client-secret" env:"AZURE_CLIENT_SECRET" description:"Client secret for Service Principal authentication" json:"-"`
Organisation string `long:"azuredevops.organisation" env:"AZURE_DEVOPS_ORGANISATION" description:"Azure DevOps organization" required:"true"`
ApiVersion string `long:"azuredevops.apiversion" env:"AZURE_DEVOPS_APIVERSION" description:"Azure DevOps API version" default:"5.1"`

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/webdevops/azure-devops-exporter

go 1.22
go 1.22.0

require (
github.com/go-resty/resty/v2 v2.11.0
Expand Down
11 changes: 7 additions & 4 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ var (
)

func main() {
initArgparser()
initLogger()
initArgparser()

logger.Infof("starting azure-devops-exporter v%s (%s; %s; by %v)", gitTag, gitCommit, runtime.Version(), Author)
logger.Info(string(opts.GetJson()))
Expand Down Expand Up @@ -82,8 +82,8 @@ func initArgparser() {
}
}

if len(opts.AzureDevops.AccessToken) == 0 {
logger.Fatalf("no Azure DevOps access token specified")
if len(opts.AzureDevops.AccessToken) == 0 && (len(opts.AzureDevops.TenantId) == 0 || len(opts.AzureDevops.ClientId) == 0 || len(opts.AzureDevops.ClientSecret) == 0) {
logger.Fatalf("neither an Azure DevOps PAT token nor client credentials (tenant ID, client ID, client secret) for service principal authentication have been provided")
}

// ensure query paths and projects are splitted by '@'
Expand Down Expand Up @@ -148,7 +148,7 @@ func initArgparser() {

// Init and build Azure authorzier
func initAzureDevOpsConnection() {
AzureDevopsClient = AzureDevops.NewAzureDevopsClient()
AzureDevopsClient = AzureDevops.NewAzureDevopsClient(logger)
if opts.AzureDevops.Url != nil {
AzureDevopsClient.HostUrl = opts.AzureDevops.Url
}
Expand All @@ -160,6 +160,9 @@ func initAzureDevOpsConnection() {

AzureDevopsClient.SetOrganization(opts.AzureDevops.Organisation)
AzureDevopsClient.SetAccessToken(opts.AzureDevops.AccessToken)
AzureDevopsClient.SetTenantId(opts.AzureDevops.TenantId)
AzureDevopsClient.SetClientId(opts.AzureDevops.ClientId)
AzureDevopsClient.SetClientSecret(opts.AzureDevops.ClientSecret)
AzureDevopsClient.SetApiVersion(opts.AzureDevops.ApiVersion)
AzureDevopsClient.SetConcurrency(opts.Request.ConcurrencyLimit)
AzureDevopsClient.SetRetries(opts.Request.Retries)
Expand Down