From 4da8d22cee66c6bff06dae89176d15d03f1baeef Mon Sep 17 00:00:00 2001 From: Mirco Michel Date: Tue, 21 Jan 2025 04:22:34 +0100 Subject: [PATCH] Add metric to monitor JFrog Access Federation validation endpoint (#154) * 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 --- README.md | 1 + artifactory/access.go | 49 +++++++++++++++ artifactory/client.go | 30 ++++++---- artifactory/utils.go | 133 ++++++++++++++++++++--------------------- collector/access.go | 27 +++++++++ collector/collector.go | 13 ++++ config/config.go | 73 +++++++++++++--------- 7 files changed, 218 insertions(+), 108 deletions(-) create mode 100644 artifactory/access.go create mode 100644 collector/access.go diff --git a/README.md b/README.md index d18254b..3405692 100755 --- a/README.md +++ b/README.md @@ -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 diff --git a/artifactory/access.go b/artifactory/access.go new file mode 100644 index 0000000..fcddc5c --- /dev/null +++ b/artifactory/access.go @@ -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 +} diff --git a/artifactory/client.go b/artifactory/client.go index 4fc3f38..09b77d5 100644 --- a/artifactory/client.go +++ b/artifactory/client.go @@ -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. @@ -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 +} diff --git a/artifactory/utils.go b/artifactory/utils.go index 5b954b2..044b9f6 100644 --- a/artifactory/utils.go +++ b/artifactory/utils.go @@ -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 @@ -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( @@ -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, @@ -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) +} \ No newline at end of file diff --git a/collector/access.go b/collector/access.go new file mode 100644 index 0000000..970cc98 --- /dev/null +++ b/collector/access.go @@ -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 +} diff --git a/collector/collector.go b/collector/collector.go index cfdb3f0..796e041 100755 --- a/collector/collector.go +++ b/collector/collector.go @@ -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() { @@ -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() @@ -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 } diff --git a/config/config.go b/config/config.go index b0eaf77..f975be9 100644 --- a/config/config.go +++ b/config/config.go @@ -14,17 +14,18 @@ import ( ) var ( - flagLogFormat = kingpin.Flag(l.FormatFlagName, l.FormatFlagHelp).Default(l.FormatDefault).Enum(l.FormatsAvailable...) - flagLogLevel = kingpin.Flag(l.LevelFlagName, l.LevelFlagHelp).Default(l.LevelDefault).Enum(l.LevelsAvailable...) - listenAddress = kingpin.Flag("web.listen-address", "Address to listen on for web interface and telemetry.").Envar("WEB_LISTEN_ADDR").Default(":9531").String() - metricsPath = kingpin.Flag("web.telemetry-path", "Path under which to expose metrics.").Envar("WEB_TELEMETRY_PATH").Default("/metrics").String() - artiScrapeURI = kingpin.Flag("artifactory.scrape-uri", "URI on which to scrape JFrog Artifactory.").Envar("ARTI_SCRAPE_URI").Default("http://localhost:8081/artifactory").String() - artiSSLVerify = kingpin.Flag("artifactory.ssl-verify", "Flag that enables SSL certificate verification for the scrape URI").Envar("ARTI_SSL_VERIFY").Default("false").Bool() - artiTimeout = kingpin.Flag("artifactory.timeout", "Timeout for trying to get stats from JFrog Artifactory.").Envar("ARTI_TIMEOUT").Default("5s").Duration() - optionalMetrics = kingpin.Flag("optional-metric", "optional metric to be enabled. Pass multiple times to enable multiple optional metrics.").PlaceHolder("metric-name").Strings() + flagLogFormat = kingpin.Flag(l.FormatFlagName, l.FormatFlagHelp).Default(l.FormatDefault).Enum(l.FormatsAvailable...) + flagLogLevel = kingpin.Flag(l.LevelFlagName, l.LevelFlagHelp).Default(l.LevelDefault).Enum(l.LevelsAvailable...) + listenAddress = kingpin.Flag("web.listen-address", "Address to listen on for web interface and telemetry.").Envar("WEB_LISTEN_ADDR").Default(":9531").String() + metricsPath = kingpin.Flag("web.telemetry-path", "Path under which to expose metrics.").Envar("WEB_TELEMETRY_PATH").Default("/metrics").String() + artiScrapeURI = kingpin.Flag("artifactory.scrape-uri", "URI on which to scrape JFrog Artifactory.").Envar("ARTI_SCRAPE_URI").Default("http://localhost:8081/artifactory").String() + artiSSLVerify = kingpin.Flag("artifactory.ssl-verify", "Flag that enables SSL certificate verification for the scrape URI").Envar("ARTI_SSL_VERIFY").Default("false").Bool() + artiTimeout = kingpin.Flag("artifactory.timeout", "Timeout for trying to get stats from JFrog Artifactory.").Envar("ARTI_TIMEOUT").Default("5s").Duration() + optionalMetrics = kingpin.Flag("optional-metric", "optional metric to be enabled. Pass multiple times to enable multiple optional metrics.").PlaceHolder("metric-name").Strings() + accessFederationTarget = kingpin.Flag("access-federation-target", "URL of Jfrog Access Federation Target server. Only required if optional metric AccessFederationValidate is enabled").Envar("ACCESS_FEDERATION_TARGET").String() ) -var optionalMetricsList = []string{"artifacts", "replication_status", "federation_status", "open_metrics"} +var optionalMetricsList = []string{"artifacts", "replication_status", "federation_status", "open_metrics", "access_federation_validate"} // Credentials represents Username and Password or API Key for // Artifactory Authentication @@ -36,22 +37,24 @@ type Credentials struct { } type OptionalMetrics struct { - Artifacts bool - ReplicationStatus bool - FederationStatus bool - OpenMetrics bool + Artifacts bool + ReplicationStatus bool + FederationStatus bool + OpenMetrics bool + AccessFederationValidate bool } // Config represents all configuration options for running the Exporter. type Config struct { - ListenAddress string - MetricsPath string - ArtiScrapeURI string - Credentials *Credentials - ArtiSSLVerify bool - ArtiTimeout time.Duration - OptionalMetrics OptionalMetrics - Logger *slog.Logger + ListenAddress string + MetricsPath string + ArtiScrapeURI string + Credentials *Credentials + ArtiSSLVerify bool + ArtiTimeout time.Duration + OptionalMetrics OptionalMetrics + AccessFederationTarget string + Logger *slog.Logger } // NewConfig Creates Config for Artifactory exporter @@ -90,11 +93,22 @@ func NewConfig() (*Config, error) { optMetrics.FederationStatus = true case "open_metrics": optMetrics.OpenMetrics = true + case "access_federation_validate": + optMetrics.AccessFederationValidate = true default: return nil, fmt.Errorf("unknown optional metric: %s. Valid optional metrics are: %s", metric, optionalMetricsList) } } + if *accessFederationTarget != "" { + _, err = url.Parse(*accessFederationTarget) + if err != nil { + return nil, err + } + } else if optMetrics.AccessFederationValidate { + return nil, fmt.Errorf("JFrog Access Federation target URL must be set if optional metric AccessFederationValidate is enabled.") + } + logger := l.New( l.Config{ Format: *flagLogFormat, @@ -102,14 +116,15 @@ func NewConfig() (*Config, error) { }, ) return &Config{ - ListenAddress: *listenAddress, - MetricsPath: *metricsPath, - ArtiScrapeURI: *artiScrapeURI, - Credentials: &credentials, - ArtiSSLVerify: *artiSSLVerify, - ArtiTimeout: *artiTimeout, - OptionalMetrics: optMetrics, - Logger: logger, + ListenAddress: *listenAddress, + MetricsPath: *metricsPath, + ArtiScrapeURI: *artiScrapeURI, + Credentials: &credentials, + ArtiSSLVerify: *artiSSLVerify, + ArtiTimeout: *artiTimeout, + OptionalMetrics: optMetrics, + AccessFederationTarget: *accessFederationTarget, + Logger: logger, }, nil }