diff --git a/README.md b/README.md index 12e1911..36898fa 100644 --- a/README.md +++ b/README.md @@ -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] diff --git a/azure-devops-client/main.go b/azure-devops-client/main.go index 704f959..ad9f2d0 100644 --- a/azure-devops-client/main.go +++ b/azure-devops-client/main.go @@ -1,6 +1,7 @@ package AzureDevopsClient import ( + "encoding/json" "errors" "fmt" "net/url" @@ -11,9 +12,12 @@ 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 @@ -21,7 +25,17 @@ type AzureDevopsClient struct { 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 @@ -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 @@ -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 @@ -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() { diff --git a/config/opts.go b/config/opts.go index 9929d81..7084576 100644 --- a/config/opts.go +++ b/config/opts.go @@ -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"` diff --git a/go.mod b/go.mod index db85716..108e290 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/main.go b/main.go index 31dccea..e52c66c 100644 --- a/main.go +++ b/main.go @@ -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())) @@ -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 '@' @@ -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 } @@ -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)