Skip to content

Commit

Permalink
Add metric to monitor JFrog Access Federation validation endpoint (#154)
Browse files Browse the repository at this point in the history
* Add Access Federation Circle of Trust validation endpoint to Artifactory module

* Add Access Federation validation metric

* Add metric to parameter list and README

* Fix SonarQube finding define constant

* Refactor response and error handling in utils (#1)

* Refactor utils to clean up request and error handling

* Readd warning for not found, move unmarshalling to after http error handling

* Only handle unmarshalling error if HTTP status not successful
  • Loading branch information
MMichel authored Jan 21, 2025
1 parent 1f6df27 commit 4da8d22
Show file tree
Hide file tree
Showing 7 changed files with 218 additions and 108 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ Supported optional metrics:
* `replication_status` - Extracts status of replication for each repository which has replication enabled. Enabling this will add the `status` label to `artifactory_replication_enabled` metric.
* `federation_status` - Extracts federation metrics. Enabling this will add two new metrics: `artifactory_federation_mirror_lag`, and `artifactory_federation_unavailable_mirror`. Please note that these metrics are only available in Artifactory Enterprise Plus and version 7.18.3 and above.
* `open_metrics` - Exposes Open Metrics from the JFrog Platform. For more information about Open Metrics, please refer to [JFrog Platform Open Metrics](https://jfrog.com/help/r/jfrog-platform-administration-documentation/open-metrics).
* `access_federation_validate` - Validates whether trust is established towards a given JFrog Access Federation target server. Requires optional parameter `access-federation-target` to be set to the URL of the target server as well as token-based authentication. For more information, please refer to [JFrog Access Federation Circle of Trust validation](https://jfrog.com/help/r/jfrog-rest-apis/validate-target-for-circle-of-trust).

### Grafana Dashboard

Expand Down
49 changes: 49 additions & 0 deletions artifactory/access.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package artifactory

import (
"encoding/json"
)

const (
accessFederationValidateEndpoint = "access/api/v1/system/federation/validate_server"
)

type AccessFederationValid struct {
Status bool
NodeId string
}

// FetchAccessFederationValidStatus checks one of the federation endpoints to see if federation is enabled
func (c *Client) FetchAccessFederationValidStatus() (AccessFederationValid, error) {
accessFederationValid := AccessFederationValid{Status: false}

// Use ping endpoint to retrieve nodeID, since this is not returned by access API
resp, err := c.FetchHTTP(pingEndpoint)
if err != nil {
return accessFederationValid, err
}
accessFederationValid.NodeId = resp.NodeId

jsonBody := map[string]string{
"url": c.accessFederationTarget,
}
jsonBytes, err := json.Marshal(jsonBody)
if err != nil {
c.logger.Error("issue when trying to marshal JSON body")
return accessFederationValid, err
}
headers := map[string]string{
"Content-Type": "application/json",
}
c.logger.Debug(
"Fetching JFrog Access Federation validation status",
"endpoint", accessFederationValidateEndpoint,
"target", c.accessFederationTarget,
)
_, err = c.PostHTTP(accessFederationValidateEndpoint, jsonBytes, &headers)
if err != nil {
return accessFederationValid, err
}
accessFederationValid.Status = true
return accessFederationValid, nil
}
30 changes: 18 additions & 12 deletions artifactory/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import (

// Client represents Artifactory HTTP Client
type Client struct {
URI string
authMethod string
cred config.Credentials
optionalMetrics config.OptionalMetrics
client *http.Client
logger *slog.Logger
URI string
authMethod string
cred config.Credentials
optionalMetrics config.OptionalMetrics
accessFederationTarget string
client *http.Client
logger *slog.Logger
}

// NewClient returns an initialized Artifactory HTTP Client.
Expand All @@ -26,11 +27,16 @@ func NewClient(conf *config.Config) *Client {
Transport: tr,
}
return &Client{
URI: conf.ArtiScrapeURI,
authMethod: conf.Credentials.AuthMethod,
cred: *conf.Credentials,
optionalMetrics: conf.OptionalMetrics,
client: client,
logger: conf.Logger,
URI: conf.ArtiScrapeURI,
authMethod: conf.Credentials.AuthMethod,
cred: *conf.Credentials,
optionalMetrics: conf.OptionalMetrics,
accessFederationTarget: conf.AccessFederationTarget,
client: client,
logger: conf.Logger,
}
}

func (c *Client) GetAccessFederationTarget() string {
return c.accessFederationTarget
}
133 changes: 66 additions & 67 deletions artifactory/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import (
"io/ioutil"
"net/http"
"slices"
"strings"
)

const (
logMsgErrAPICall = "There was an error making API call"
logMsgErrUnmarshall = "There was an error when trying to unmarshal the API Error"
logMsgErrRespBody = "There was an error reading response body"
)

// APIErrors represents Artifactory API Error response
Expand Down Expand Up @@ -39,7 +41,7 @@ var (
}
)

func (c *Client) makeRequest(method string, path string, body []byte) (*http.Response, error) {
func (c *Client) makeRequest(method string, path string, body []byte, headers **map[string]string) (*http.Response, error) {
req, err := http.NewRequest(method, path, bytes.NewBuffer(body))
if err != nil {
c.logger.Error(
Expand All @@ -56,107 +58,96 @@ func (c *Client) makeRequest(method string, path string, body []byte) (*http.Res
default:
return nil, fmt.Errorf("Artifactory Auth (%s) method is not supported", c.authMethod)
}
return c.client.Do(req)
}

func (c *Client) procRespErr(resp *http.Response, fPath string) (*ApiResponse, error) {
var apiErrors APIErrors
bodyBytes, _ := ioutil.ReadAll(resp.Body)
if err := json.Unmarshal(bodyBytes, &apiErrors); err != nil {
c.logger.Error(
logMsgErrUnmarshall,
"err", err.Error(),
)
return nil, &UnmarshalError{
message: err.Error(),
endpoint: fPath,
if headers != nil {
for key, value := range **headers {
req.Header.Set(key, value)
}
}
c.logger.Error(
logMsgErrAPICall,
"endpoint", fPath,
"err", fmt.Sprintf("%v", apiErrors.Errors),
"status", resp.StatusCode,
)
return nil, &APIError{
message: fmt.Sprintf("%v", apiErrors.Errors),
endpoint: fPath,
// status: resp.StatusCode, // Maybe it would be worth returning it too? As with http.StatusNotFound.
}
return c.client.Do(req)
}

// FetchHTTP is a wrapper function for making all Get API calls
func (c *Client) FetchHTTP(path string) (*ApiResponse, error) {
var response ApiResponse
fullPath := fmt.Sprintf("%s/api/%s", c.URI, path)
c.logger.Debug(
"Fetching http",
"path", fullPath,
)
resp, err := c.makeRequest("GET", fullPath, nil)
func (c *Client) handleResponse(resp *http.Response, fullPath string) (*ApiResponse, error) {
var apiErrors APIErrors
bodyBytes, err := ioutil.ReadAll(resp.Body)
if err != nil {
c.logger.Error(
logMsgErrAPICall,
"endpoint", fullPath,
"err", err.Error(),
logMsgErrRespBody,
"err", err,
)
return nil, err
}
response.NodeId = resp.Header.Get("x-artifactory-node-id")
defer resp.Body.Close()

if resp.StatusCode == http.StatusNotFound {
var apiErrors APIErrors
bodyBytes, _ := ioutil.ReadAll(resp.Body)
if !slices.Contains(httpSuccCodes, resp.StatusCode) {
if err := json.Unmarshal(bodyBytes, &apiErrors); err != nil {
c.logger.Error(
logMsgErrUnmarshall,
"err", err,
"err", err.Error(),
)
return nil, &UnmarshalError{
message: err.Error(),
endpoint: fullPath,
}
}
c.logger.Warn(
"The endpoint does not exist",
if resp.StatusCode == http.StatusNotFound {
c.logger.Warn(
"The endpoint does not exist",
"endpoint", fullPath,
"err", fmt.Sprintf("%v", apiErrors.Errors),
"status", http.StatusNotFound,
)
return nil, &APIError{
message: fmt.Sprintf("%v", apiErrors.Errors),
endpoint: fullPath,
status: http.StatusNotFound,
}
}
c.logger.Error(
logMsgErrAPICall,
"endpoint", fullPath,
"err", fmt.Sprintf("%v", apiErrors.Errors),
"status", http.StatusNotFound,
"status", resp.StatusCode,
)
return nil, &APIError{
message: fmt.Sprintf("%v", apiErrors.Errors),
endpoint: fullPath,
status: http.StatusNotFound,
// status: resp.StatusCode, // Maybe it would be worth returning it too? As with http.StatusNotFound.
}
}

if !slices.Contains(httpSuccCodes, resp.StatusCode) {
return c.procRespErr(resp, fullPath)
response := &ApiResponse{
Body: bodyBytes,
NodeId: resp.Header.Get("x-artifactory-node-id"),
}
return response, nil
}

bodyBytes, err := ioutil.ReadAll(resp.Body)
// FetchHTTP is a wrapper function for making all Get API calls
func (c *Client) FetchHTTP(path string) (*ApiResponse, error) {
fullPath := fmt.Sprintf("%s/api/%s", c.URI, path)
c.logger.Debug(
"Fetching http",
"path", fullPath,
)
resp, err := c.makeRequest("GET", fullPath, nil, nil)
if err != nil {
c.logger.Error(
"There was an error reading response body",
logMsgErrAPICall,
"endpoint", fullPath,
"err", err.Error(),
)
return nil, err
}
response.Body = bodyBytes

return &response, nil
defer resp.Body.Close()
return c.handleResponse(resp, fullPath)
}

// QueryAQL is a wrapper function for making an query to AQL endpoint
func (c *Client) QueryAQL(query []byte) (*ApiResponse, error) {
var response ApiResponse
fullPath := fmt.Sprintf("%s/api/search/aql", c.URI)
c.logger.Debug(
"Running AQL query",
"path", fullPath,
)
resp, err := c.makeRequest("POST", fullPath, query)
resp, err := c.makeRequest("POST", fullPath, query, nil)
if err != nil {
c.logger.Error(
logMsgErrAPICall,
Expand All @@ -165,20 +156,28 @@ func (c *Client) QueryAQL(query []byte) (*ApiResponse, error) {
)
return nil, err
}
response.NodeId = resp.Header.Get("x-artifactory-node-id")
defer resp.Body.Close()
if !slices.Contains(httpSuccCodes, resp.StatusCode) {
return c.procRespErr(resp, fullPath)
}
return c.handleResponse(resp, fullPath)
}

bodyBytes, err := ioutil.ReadAll(resp.Body)
// PostHTTP is a wrapper function for making all Post API calls
// Note: the API endpoint (e.g. "/artifactory" or "/access") needs to be part of path
func (c *Client) PostHTTP(path string, body []byte, headers *map[string]string) (*ApiResponse, error) {
artifactoryURI := strings.TrimSuffix(c.URI, "/artifactory")
fullPath := fmt.Sprintf("%s/%s", artifactoryURI, path)
c.logger.Debug(
"Posting http",
"path", fullPath,
)
resp, err := c.makeRequest("POST", fullPath, body, &headers)
if err != nil {
c.logger.Error(
"There was an error reading response body",
logMsgErrAPICall,
"endpoint", fullPath,
"err", err.Error(),
)
return nil, err
}
response.Body = bodyBytes
return &response, nil
}
defer resp.Body.Close()
return c.handleResponse(resp, fullPath)
}
27 changes: 27 additions & 0 deletions collector/access.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package collector

import (
"github.com/prometheus/client_golang/prometheus"
)

func (e *Exporter) exportAccessFederationValidate(ch chan<- prometheus.Metric) error {
// Fetch Federation Mirror Lags
accessFederationValid, err := e.client.FetchAccessFederationValidStatus()
if err != nil {
e.logger.Warn(
"JFrog Access Federation Circle of Trust was not successfully validated",
"target", e.client.GetAccessFederationTarget(),
"status", accessFederationValid.Status,
"err", err.Error(),
)
e.totalAPIErrors.Inc()
}
value := convArtiToPromBool(accessFederationValid.Status)
e.logger.Debug(
logDbgMsgRegMetric,
"metric", "accessFederationValid",
"value", value,
)
ch <- prometheus.MustNewConstMetric(accessMetrics["accessFederationValid"], prometheus.GaugeValue, value, accessFederationValid.NodeId)
return nil
}
13 changes: 13 additions & 0 deletions collector/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ var (
openMetrics = metrics{
"openMetrics": newMetric("open_metrics", "openmetrics", "OpenMetrics proxied from JFrog Platform", defaultLabelNames),
}
accessMetrics = metrics{
"accessFederationValid": newMetric("access_federation_valid", "access", "Is JFrog Access Federation valid (1 = Circle of Trust validated)", defaultLabelNames),
}
)

func init() {
Expand Down Expand Up @@ -108,6 +111,11 @@ func (e *Exporter) Describe(ch chan<- *prometheus.Desc) {
ch <- m
}
}
if e.optionalMetrics.AccessFederationValidate {
for _, m := range accessMetrics {
ch <- m
}
}

ch <- e.up.Desc()
ch <- e.totalScrapes.Desc()
Expand Down Expand Up @@ -181,5 +189,10 @@ func (e *Exporter) scrape(ch chan<- prometheus.Metric) (up float64) {
e.exportFederationUnavailableMirrors(ch)
}

// Get Access Federation Validation metric
if e.optionalMetrics.AccessFederationValidate {
e.exportAccessFederationValidate(ch)
}

return 1
}
Loading

0 comments on commit 4da8d22

Please sign in to comment.