-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add ironic metrics #53
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
base: main
Are you sure you want to change the base?
Changes from all commits
309de05
9f69a1b
b6d961f
e0e23db
2b59abe
3b5e871
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| package ironic | ||
|
|
||
| import ( | ||
| "context" | ||
| "database/sql" | ||
| "log/slog" | ||
|
|
||
| "github.com/prometheus/client_golang/prometheus" | ||
|
|
||
| ironicdb "github.com/vexxhost/openstack_database_exporter/internal/db/ironic" | ||
| ) | ||
|
|
||
| // BaremetalCollector is the umbrella collector for Ironic baremetal metrics | ||
| type BaremetalCollector struct { | ||
| queries *ironicdb.Queries | ||
| logger *slog.Logger | ||
|
|
||
| // Single up metric for baremetal service | ||
| upMetric *prometheus.Desc | ||
|
|
||
| // Sub-collectors | ||
| nodesCollector *NodesCollector | ||
| } | ||
|
|
||
| // NewBaremetalCollector creates a new umbrella collector for Ironic baremetal service | ||
| func NewBaremetalCollector(db *sql.DB, logger *slog.Logger) *BaremetalCollector { | ||
| return &BaremetalCollector{ | ||
| queries: ironicdb.New(db), | ||
| logger: logger.With( | ||
| "namespace", Namespace, | ||
| "subsystem", Subsystem, | ||
| "collector", "baremetal", | ||
| ), | ||
|
|
||
| upMetric: prometheus.NewDesc( | ||
| prometheus.BuildFQName(Namespace, Subsystem, "up"), | ||
| "Whether the Ironic baremetal service is up", | ||
| nil, | ||
| nil, | ||
| ), | ||
|
|
||
| // Initialize sub-collectors | ||
| nodesCollector: NewNodesCollector(db, logger), | ||
| } | ||
| } | ||
|
|
||
| // Describe implements prometheus.Collector | ||
| func (c *BaremetalCollector) Describe(ch chan<- *prometheus.Desc) { | ||
| ch <- c.upMetric | ||
| c.nodesCollector.Describe(ch) | ||
| } | ||
|
|
||
| // Collect implements prometheus.Collector | ||
| func (c *BaremetalCollector) Collect(ch chan<- prometheus.Metric) { | ||
| ctx := context.Background() | ||
|
|
||
| // Query node metrics once and reuse the result | ||
| nodes, err := c.queries.GetNodeMetrics(ctx) | ||
| if err != nil { | ||
| c.logger.Error("failed to query Ironic database", "error", err) | ||
| ch <- prometheus.MustNewConstMetric(c.upMetric, prometheus.GaugeValue, 0) | ||
| return | ||
| } | ||
|
|
||
| // Collect nodes metrics using the already-fetched data | ||
| if err := c.nodesCollector.CollectFromRows(ch, nodes); err != nil { | ||
| c.logger.Error("nodes collector failed", "error", err) | ||
| } | ||
|
|
||
| ch <- prometheus.MustNewConstMetric(c.upMetric, prometheus.GaugeValue, 1) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| package ironic | ||
|
|
||
| import ( | ||
| "database/sql" | ||
| "regexp" | ||
| "testing" | ||
|
|
||
| "github.com/DATA-DOG/go-sqlmock" | ||
| ironicdb "github.com/vexxhost/openstack_database_exporter/internal/db/ironic" | ||
| "github.com/vexxhost/openstack_database_exporter/internal/testutil" | ||
| ) | ||
|
|
||
| func TestBaremetalCollector(t *testing.T) { | ||
| tests := []testutil.CollectorTestCase{ | ||
| { | ||
| Name: "successful collection", | ||
| SetupMock: func(mock sqlmock.Sqlmock) { | ||
| rows := sqlmock.NewRows([]string{ | ||
| "uuid", "name", "power_state", "provision_state", "maintenance", | ||
| "resource_class", "console_enabled", "retired", "retired_reason", | ||
| }).AddRow( | ||
| "550e8400-e29b-41d4-a716-446655440000", "node-1", "power on", "active", false, | ||
| "baremetal", true, false, "", | ||
| ) | ||
| mock.ExpectQuery(regexp.QuoteMeta(ironicdb.GetNodeMetrics)).WillReturnRows(rows) | ||
| }, | ||
| ExpectedMetrics: `# HELP openstack_ironic_node Ironic node status | ||
| # TYPE openstack_ironic_node gauge | ||
| openstack_ironic_node{console_enabled="true",id="550e8400-e29b-41d4-a716-446655440000",maintenance="false",name="node-1",power_state="power on",provision_state="active",resource_class="baremetal",retired="false",retired_reason=""} 1 | ||
| # HELP openstack_ironic_up Whether the Ironic baremetal service is up | ||
| # TYPE openstack_ironic_up gauge | ||
| openstack_ironic_up 1 | ||
| `, | ||
| }, | ||
| { | ||
| Name: "database connection failure", | ||
| SetupMock: func(mock sqlmock.Sqlmock) { | ||
| mock.ExpectQuery(regexp.QuoteMeta(ironicdb.GetNodeMetrics)).WillReturnError(sql.ErrConnDone) | ||
| }, | ||
| ExpectedMetrics: `# HELP openstack_ironic_up Whether the Ironic baremetal service is up | ||
| # TYPE openstack_ironic_up gauge | ||
| openstack_ironic_up 0 | ||
| `, | ||
| }, | ||
| { | ||
| Name: "skips nodes with null uuid", | ||
| SetupMock: func(mock sqlmock.Sqlmock) { | ||
| rows := sqlmock.NewRows([]string{ | ||
| "uuid", "name", "power_state", "provision_state", "maintenance", | ||
| "resource_class", "console_enabled", "retired", "retired_reason", | ||
| }).AddRow( | ||
| nil, "node-no-uuid", "power on", "active", false, | ||
| "baremetal", true, false, "", | ||
| ).AddRow( | ||
| "550e8400-e29b-41d4-a716-446655440000", "node-1", "power on", "active", false, | ||
| "baremetal", true, false, "", | ||
| ) | ||
| mock.ExpectQuery(regexp.QuoteMeta(ironicdb.GetNodeMetrics)).WillReturnRows(rows) | ||
| }, | ||
| ExpectedMetrics: `# HELP openstack_ironic_node Ironic node status | ||
| # TYPE openstack_ironic_node gauge | ||
| openstack_ironic_node{console_enabled="true",id="550e8400-e29b-41d4-a716-446655440000",maintenance="false",name="node-1",power_state="power on",provision_state="active",resource_class="baremetal",retired="false",retired_reason=""} 1 | ||
| # HELP openstack_ironic_up Whether the Ironic baremetal service is up | ||
| # TYPE openstack_ironic_up gauge | ||
| openstack_ironic_up 1 | ||
| `, | ||
| }, | ||
| } | ||
|
|
||
| testutil.RunCollectorTests(t, tests, NewBaremetalCollector) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| package ironic | ||
|
|
||
| import ( | ||
| "log/slog" | ||
|
|
||
| "github.com/prometheus/client_golang/prometheus" | ||
| "github.com/vexxhost/openstack_database_exporter/internal/db" | ||
| ) | ||
|
|
||
| // Namespace and subsystem constants | ||
| const ( | ||
| Namespace = "openstack" | ||
| Subsystem = "ironic" | ||
| ) | ||
|
|
||
| // RegisterCollectors registers all Ironic collectors with the given database and logger | ||
| func RegisterCollectors(registry *prometheus.Registry, databaseURL string, logger *slog.Logger) { | ||
| if databaseURL == "" { | ||
| logger.Info("Collector not loaded", "service", "ironic", "reason", "database URL not configured") | ||
| return | ||
| } | ||
|
|
||
| conn, err := db.Connect(databaseURL) | ||
| if err != nil { | ||
| logger.Error("Failed to connect to database", "service", "ironic", "error", err) | ||
| return | ||
| } | ||
|
|
||
| registry.MustRegister(NewBaremetalCollector(conn, logger)) | ||
|
|
||
| logger.Info("Registered collectors", "service", "ironic") | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,140 @@ | ||||||
| package ironic | ||||||
|
|
||||||
| import ( | ||||||
| "context" | ||||||
| "database/sql" | ||||||
| "log/slog" | ||||||
|
|
||||||
| "github.com/prometheus/client_golang/prometheus" | ||||||
| ironicdb "github.com/vexxhost/openstack_database_exporter/internal/db/ironic" | ||||||
| ) | ||||||
|
|
||||||
| // maxLabelLength is the maximum length for free-form label values to prevent | ||||||
| // unbounded cardinality and memory usage in Prometheus. | ||||||
| const maxLabelLength = 128 | ||||||
|
|
||||||
| // truncateLabel truncates a string to maxLabelLength. | ||||||
| func truncateLabel(s string) string { | ||||||
| if len(s) > maxLabelLength { | ||||||
| return s[:maxLabelLength] | ||||||
| } | ||||||
| return s | ||||||
| } | ||||||
|
|
||||||
| // NodesCollector collects metrics about Ironic nodes | ||||||
| type NodesCollector struct { | ||||||
| queries *ironicdb.Queries | ||||||
| logger *slog.Logger | ||||||
|
Comment on lines
25
to
27
|
||||||
|
|
||||||
| // Individual node metrics | ||||||
| nodeMetric *prometheus.Desc | ||||||
| } | ||||||
|
|
||||||
| // NewNodesCollector creates a new NodesCollector | ||||||
| func NewNodesCollector(db *sql.DB, logger *slog.Logger) *NodesCollector { | ||||||
| return &NodesCollector{ | ||||||
| queries: ironicdb.New(db), | ||||||
| logger: logger.With( | ||||||
| "namespace", Namespace, | ||||||
| "subsystem", Subsystem, | ||||||
| "collector", "nodes", | ||||||
| ), | ||||||
|
|
||||||
| nodeMetric: prometheus.NewDesc( | ||||||
| prometheus.BuildFQName(Namespace, Subsystem, "node"), | ||||||
| "Ironic node status", | ||||||
| []string{ | ||||||
| "id", "name", "power_state", "provision_state", | ||||||
|
||||||
| "id", "name", "power_state", "provision_state", | |
| "uuid", "name", "power_state", "provision_state", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Skip this — the original exporter uses id, and we need drop-in compatibility
Copilot
AI
Feb 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
retired_reason is free-form text and can be high-cardinality / unbounded-length, which is strongly discouraged for Prometheus labels and can cause memory blowups and slow scrapes. Consider removing retired_reason as a label (or mapping it to a bounded set), and exposing it via logs or an alternate mechanism; keeping only bounded-state labels (or emitting separate state metrics) will be safer.
Copilot
AI
Feb 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NodesCollector does not implement prometheus.Collector because Collect returns an error (the interface requires Collect(chan<- Metric) with no return value). Either update the comment to avoid claiming interface conformance, or change the method signature/structure so it actually implements prometheus.Collector.
Copilot
AI
Feb 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NodesCollector is documented as implementing prometheus.Collector, but its Collect method signature returns error, so it does not satisfy the interface (Collect(chan<- Metric) has no return). To reduce confusion, either (a) change it to match the Prometheus interface and handle errors internally, or (b) rename this method (e.g., CollectOnce/CollectMetrics) and adjust the comment so it’s clear it’s a helper used by the umbrella collector.
Copilot
AI
Feb 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Only retired_reason is truncated, but name (and potentially other free-form string labels like resource_class) can also be arbitrarily long. To enforce the stated maxLabelLength policy consistently, consider applying truncateLabel to other label values that come directly from the DB.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mohammed Naser (@mnaser) I think we can skip this:)
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this file,
db *sql.DBis stored on the struct but not referenced afterwards. Consider removing it and relying onqueries/ sub-collectors to avoid unused state.