From 617e12791aa0471e262aaf21def1a289cd9d0fd8 Mon Sep 17 00:00:00 2001 From: kekkokers Date: Sun, 10 Dec 2023 16:50:37 +0200 Subject: [PATCH] feat: add cluster license collector Signed-off-by: kekkokers --- collector/cluster_license.go | 176 ++++++++++++++++++++++++++ collector/cluster_license_response.go | 32 +++++ collector/cluster_license_test.go | 102 +++++++++++++++ fixtures/clusterlicense/basic.json | 14 ++ fixtures/clusterlicense/platinum.json | 15 +++ main.go | 7 + 6 files changed, 346 insertions(+) create mode 100644 collector/cluster_license.go create mode 100644 collector/cluster_license_response.go create mode 100644 collector/cluster_license_test.go create mode 100644 fixtures/clusterlicense/basic.json create mode 100644 fixtures/clusterlicense/platinum.json diff --git a/collector/cluster_license.go b/collector/cluster_license.go new file mode 100644 index 00000000..6a6c653d --- /dev/null +++ b/collector/cluster_license.go @@ -0,0 +1,176 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + + "github.com/go-kit/log" + "github.com/go-kit/log/level" + "github.com/prometheus/client_golang/prometheus" +) + +type clusterLicenseMetric struct { + Type prometheus.ValueType + Desc *prometheus.Desc + Value func(clusterLicenseStats clusterLicenseResponse) float64 + Labels func(clusterLicenseStats clusterLicenseResponse) []string +} + +var ( + defaultClusterLicenseLabels = []string{"cluster_license_type"} + defaultClusterLicenseValues = func(clusterLicense clusterLicenseResponse) []string { + return []string{clusterLicense.License.Type} + } +) + +// License Information Struct +type ClusterLicense struct { + logger log.Logger + client *http.Client + url *url.URL + + clusterLicenseMetrics []*clusterLicenseMetric +} + +// NewClusterLicense defines ClusterLicense Prometheus metrics +func NewClusterLicense(logger log.Logger, client *http.Client, url *url.URL) *ClusterLicense { + return &ClusterLicense{ + logger: logger, + client: client, + url: url, + + clusterLicenseMetrics: []*clusterLicenseMetric{ + { + Type: prometheus.GaugeValue, + Desc: prometheus.NewDesc( + prometheus.BuildFQName(namespace, "cluster_license", "max_nodes"), + "The max amount of nodes allowed by the license", + defaultClusterLicenseLabels, nil, + ), + Value: func(clusterLicenseStats clusterLicenseResponse) float64 { + return float64(clusterLicenseStats.License.MaxNodes) + }, + Labels: defaultClusterLicenseValues, + }, + { + Type: prometheus.GaugeValue, + Desc: prometheus.NewDesc( + prometheus.BuildFQName(namespace, "cluster_license", "issue_date_in_millis"), + "License issue date in milliseconds", + defaultClusterLicenseLabels, nil, + ), + Value: func(clusterLicenseStats clusterLicenseResponse) float64 { + return float64(clusterLicenseStats.License.IssueDateInMillis) + }, + Labels: defaultClusterLicenseValues, + }, + { + Type: prometheus.GaugeValue, + Desc: prometheus.NewDesc( + prometheus.BuildFQName(namespace, "cluster_license", "expiry_date_in_millis"), + "License expiry date in milliseconds", + defaultClusterLicenseLabels, nil, + ), + Value: func(clusterLicenseStats clusterLicenseResponse) float64 { + return float64(clusterLicenseStats.License.ExpiryDateInMillis) + }, + Labels: defaultClusterLicenseValues, + }, + { + Type: prometheus.GaugeValue, + Desc: prometheus.NewDesc( + prometheus.BuildFQName(namespace, "cluster_license", "start_date_in_millis"), + "License start date in milliseconds", + defaultClusterLicenseLabels, nil, + ), + Value: func(clusterLicenseStats clusterLicenseResponse) float64 { + return float64(clusterLicenseStats.License.StartDateInMillis) + }, + Labels: defaultClusterLicenseValues, + }, + }, + } +} + +// Describe adds License metrics descriptions +func (cl *ClusterLicense) Describe(ch chan<- *prometheus.Desc) { + for _, metric := range cl.clusterLicenseMetrics { + ch <- metric.Desc + } +} + +func (cl *ClusterLicense) fetchAndDecodeClusterLicense() (clusterLicenseResponse, error) { + var clr clusterLicenseResponse + + u := *cl.url + u.Path = path.Join(u.Path, "/_license") + res, err := cl.client.Get(u.String()) + if err != nil { + return clr, fmt.Errorf("failed to get license stats from %s://%s:%s%s: %s", + u.Scheme, u.Hostname(), u.Port(), u.Path, err) + } + + defer func() { + err = res.Body.Close() + if err != nil { + level.Warn(cl.logger).Log( + "msg", "failed to close http.Client", + "err", err, + ) + } + }() + + if res.StatusCode != http.StatusOK { + return clr, fmt.Errorf("HTTP Request failed with code %d", res.StatusCode) + } + + bts, err := io.ReadAll(res.Body) + if err != nil { + return clr, err + } + + if err := json.Unmarshal(bts, &clr); err != nil { + return clr, err + } + + return clr, nil +} + +// Collect gets ClusterLicense metric values +func (cl *ClusterLicense) Collect(ch chan<- prometheus.Metric) { + + clusterLicenseResp, err := cl.fetchAndDecodeClusterLicense() + if err != nil { + level.Warn(cl.logger).Log( + "msg", "failed to fetch and decode license stats", + "err", err, + ) + return + } + + for _, metric := range cl.clusterLicenseMetrics { + ch <- prometheus.MustNewConstMetric( + metric.Desc, + metric.Type, + metric.Value(clusterLicenseResp), + metric.Labels(clusterLicenseResp)..., + ) + } +} diff --git a/collector/cluster_license_response.go b/collector/cluster_license_response.go new file mode 100644 index 00000000..8bef6792 --- /dev/null +++ b/collector/cluster_license_response.go @@ -0,0 +1,32 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector + +import "time" + +type clusterLicenseResponse struct { + License struct { + Status string `json:"status"` + UID string `json:"uid"` + Type string `json:"type"` + IssueDate time.Time `json:"issue_date"` + IssueDateInMillis int64 `json:"issue_date_in_millis"` + ExpiryDate time.Time `json:"expiry_date"` + ExpiryDateInMillis int64 `json:"expiry_date_in_millis"` + MaxNodes int `json:"max_nodes"` + IssuedTo string `json:"issued_to"` + Issuer string `json:"issuer"` + StartDateInMillis int64 `json:"start_date_in_millis"` + } `json:"license"` +} diff --git a/collector/cluster_license_test.go b/collector/cluster_license_test.go new file mode 100644 index 00000000..8af53bf2 --- /dev/null +++ b/collector/cluster_license_test.go @@ -0,0 +1,102 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + + "github.com/go-kit/log" + "github.com/prometheus/client_golang/prometheus/testutil" +) + +func TestIndicesHealth(t *testing.T) { + // Testcases created using: + // docker run -d -p 9200:9200 elasticsearch:VERSION + // curl http://localhost:9200/_license + tests := []struct { + name string + file string + want string + }{ + { + name: "basic", + file: "../fixtures/clusterlicense/basic.json", + want: ` + # HELP elasticsearch_cluster_license_expiry_date_in_millis License expiry date in milliseconds + # TYPE elasticsearch_cluster_license_expiry_date_in_millis gauge + elasticsearch_cluster_license_expiry_date_in_millis{cluster_license_type="basic"} 0 + # HELP elasticsearch_cluster_license_issue_date_in_millis License issue date in milliseconds + # TYPE elasticsearch_cluster_license_issue_date_in_millis gauge + elasticsearch_cluster_license_issue_date_in_millis{cluster_license_type="basic"} 1.702196247064e+12 + # HELP elasticsearch_cluster_license_max_nodes The max amount of nodes allowed by the license + # TYPE elasticsearch_cluster_license_max_nodes gauge + elasticsearch_cluster_license_max_nodes{cluster_license_type="basic"} 1000 + # HELP elasticsearch_cluster_license_start_date_in_millis License start date in milliseconds + # TYPE elasticsearch_cluster_license_start_date_in_millis gauge + elasticsearch_cluster_license_start_date_in_millis{cluster_license_type="basic"} -1 + `, + }, + { + name: "platinum", + file: "../fixtures/clusterlicense/platinum.json", + want: ` + # HELP elasticsearch_cluster_license_expiry_date_in_millis License expiry date in milliseconds + # TYPE elasticsearch_cluster_license_expiry_date_in_millis gauge + elasticsearch_cluster_license_expiry_date_in_millis{cluster_license_type="platinum"} 1.714521599999e+12 + # HELP elasticsearch_cluster_license_issue_date_in_millis License issue date in milliseconds + # TYPE elasticsearch_cluster_license_issue_date_in_millis gauge + elasticsearch_cluster_license_issue_date_in_millis{cluster_license_type="platinum"} 1.6192224e+12 + # HELP elasticsearch_cluster_license_max_nodes The max amount of nodes allowed by the license + # TYPE elasticsearch_cluster_license_max_nodes gauge + elasticsearch_cluster_license_max_nodes{cluster_license_type="platinum"} 10 + # HELP elasticsearch_cluster_license_start_date_in_millis License start date in milliseconds + # TYPE elasticsearch_cluster_license_start_date_in_millis gauge + elasticsearch_cluster_license_start_date_in_millis{cluster_license_type="platinum"} 1.6192224e+12 + `, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := os.Open(tt.file) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.Copy(w, f) + })) + + defer ts.Close() + + u, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + c := NewClusterLicense(log.NewNopLogger(), http.DefaultClient, u) + + if err := testutil.CollectAndCompare(c, strings.NewReader(tt.want)); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/fixtures/clusterlicense/basic.json b/fixtures/clusterlicense/basic.json new file mode 100644 index 00000000..67cdf833 --- /dev/null +++ b/fixtures/clusterlicense/basic.json @@ -0,0 +1,14 @@ +{ + "license": { + "status": "active", + "uid": "redacted", + "type": "basic", + "issue_date": "2023-12-10T08:17:27.064Z", + "issue_date_in_millis": 1702196247064, + "max_nodes": 1000, + "max_resource_units": null, + "issued_to": "redacted", + "issuer": "elasticsearch", + "start_date_in_millis": -1 + } +} diff --git a/fixtures/clusterlicense/platinum.json b/fixtures/clusterlicense/platinum.json new file mode 100644 index 00000000..b9b06009 --- /dev/null +++ b/fixtures/clusterlicense/platinum.json @@ -0,0 +1,15 @@ +{ + "license": { + "status": "active", + "uid": "redacted", + "type": "platinum", + "issue_date": "2021-04-24T00:00:00.000Z", + "issue_date_in_millis": 1619222400000, + "expiry_date": "2024-04-30T23:59:59.999Z", + "expiry_date_in_millis": 1714521599999, + "max_nodes": 10, + "issued_to": "redacted", + "issuer": "API", + "start_date_in_millis": 1619222400000 + } +} diff --git a/main.go b/main.go index c7db1def..5eee49f8 100644 --- a/main.go +++ b/main.go @@ -92,6 +92,9 @@ func main() { esClusterInfoInterval = kingpin.Flag("es.clusterinfo.interval", "Cluster info update interval for the cluster label"). Default("5m").Duration() + esExportLicense = kingpin.Flag("es.license", + "Export license information"). + Default("false").Bool() esCA = kingpin.Flag("es.ca", "Path to PEM file that contains trusted Certificate Authorities for the Elasticsearch connection."). Default("").String() @@ -229,6 +232,10 @@ func main() { prometheus.MustRegister(collector.NewIlmIndicies(logger, httpClient, esURL)) } + if *esExportLicense { + prometheus.MustRegister(collector.NewClusterLicense(logger, httpClient, esURL)) + } + // Create a context that is cancelled on SIGKILL or SIGINT. ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill) defer cancel()