Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add metric to monitor JFrog Access Federation validation endpoint #154

Merged
merged 5 commits into from
Jan 21, 2025
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
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
Loading