Skip to content
Open
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
5 changes: 5 additions & 0 deletions cmd/openstack-database-exporter/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ var (
"glance.database-url",
"Glance database connection URL (oslo.db format)",
).Envar("GLANCE_DATABASE_URL").String()
ironicDatabaseURL = kingpin.Flag(
"ironic.database-url",
"Ironic database connection URL (oslo.db format)",
).Envar("IRONIC_DATABASE_URL").String()
keystoneDatabaseURL = kingpin.Flag(
"keystone.database-url",
"Keystone database connection URL (oslo.db format)",
Expand Down Expand Up @@ -73,6 +77,7 @@ func main() {
reg := collector.NewRegistry(collector.Config{
CinderDatabaseURL: *cinderDatabaseURL,
GlanceDatabaseURL: *glanceDatabaseURL,
IronicDatabaseURL: *ironicDatabaseURL,
KeystoneDatabaseURL: *keystoneDatabaseURL,
MagnumDatabaseURL: *magnumDatabaseURL,
ManilaDatabaseURL: *manilaDatabaseURL,
Expand Down
3 changes: 3 additions & 0 deletions internal/collector/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/vexxhost/openstack_database_exporter/internal/collector/cinder"
"github.com/vexxhost/openstack_database_exporter/internal/collector/glance"
"github.com/vexxhost/openstack_database_exporter/internal/collector/ironic"
"github.com/vexxhost/openstack_database_exporter/internal/collector/keystone"
"github.com/vexxhost/openstack_database_exporter/internal/collector/magnum"
"github.com/vexxhost/openstack_database_exporter/internal/collector/manila"
Expand All @@ -22,6 +23,7 @@ const (
type Config struct {
CinderDatabaseURL string
GlanceDatabaseURL string
IronicDatabaseURL string
KeystoneDatabaseURL string
MagnumDatabaseURL string
ManilaDatabaseURL string
Expand All @@ -35,6 +37,7 @@ func NewRegistry(cfg Config, logger *slog.Logger) *prometheus.Registry {

cinder.RegisterCollectors(reg, cfg.CinderDatabaseURL, logger)
glance.RegisterCollectors(reg, cfg.GlanceDatabaseURL, logger)
ironic.RegisterCollectors(reg, cfg.IronicDatabaseURL, logger)
keystone.RegisterCollectors(reg, cfg.KeystoneDatabaseURL, logger)
magnum.RegisterCollectors(reg, cfg.MagnumDatabaseURL, logger)
manila.RegisterCollectors(reg, cfg.ManilaDatabaseURL, logger)
Expand Down
71 changes: 71 additions & 0 deletions internal/collector/ironic/baremetal.go
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
Comment on lines 14 to 16
Copy link

Copilot AI Feb 12, 2026

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.DB is stored on the struct but not referenced afterwards. Consider removing it and relying on queries / sub-collectors to avoid unused state.

Copilot uses AI. Check for mistakes.

// 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)
}
71 changes: 71 additions & 0 deletions internal/collector/ironic/baremetal_test.go
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)
}
32 changes: 32 additions & 0 deletions internal/collector/ironic/ironic.go
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")
}
140 changes: 140 additions & 0 deletions internal/collector/ironic/nodes.go
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
Copy link

Copilot AI Feb 12, 2026

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.DB is stored on the struct but never used. Dropping the field (and only keeping queries) would reduce surface area and avoid confusion about which DB handle is intended to be used.

Copilot uses AI. Check for mistakes.

// 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",
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The label key id is populated with uuid (see emission below). Since this is a new metric, consider renaming the label to uuid to avoid confusion for metric consumers (and update the test expectations accordingly).

Suggested change
"id", "name", "power_state", "provision_state",
"uuid", "name", "power_state", "provision_state",

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

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

"resource_class", "maintenance", "console_enabled", "retired", "retired_reason",
},
Comment on lines +46 to +49
Copy link

Copilot AI Feb 12, 2026

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 uses AI. Check for mistakes.
nil,
),
}
}

// Describe implements prometheus.Collector
func (c *NodesCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.nodeMetric
}

// Collect queries the database and collects node metrics into the provided channel.
func (c *NodesCollector) Collect(ch chan<- prometheus.Metric) error {
Comment on lines 55 to 61
Copy link

Copilot AI Feb 12, 2026

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 uses AI. Check for mistakes.
ctx := context.Background()

nodes, err := c.queries.GetNodeMetrics(ctx)
if err != nil {
c.logger.Error("failed to get node metrics", "error", err)
return err
}

return c.CollectFromRows(ch, nodes)
}
Comment on lines 55 to 71
Copy link

Copilot AI Feb 12, 2026

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 uses AI. Check for mistakes.

// CollectFromRows emits node metrics from pre-fetched rows.
func (c *NodesCollector) CollectFromRows(ch chan<- prometheus.Metric, nodes []ironicdb.GetNodeMetricsRow) error {
for _, node := range nodes {
// Skip nodes with empty UUID to avoid duplicate label sets
if !node.Uuid.Valid || node.Uuid.String == "" {
nodeName := ""
if node.Name.Valid {
nodeName = node.Name.String
}
c.logger.Debug("skipping node with empty UUID", "name", nodeName)
continue
}
// Individual node status metric
maintenance := "false"
if node.Maintenance.Valid && node.Maintenance.Bool {
maintenance = "true"
}

powerState := "unknown"
if node.PowerState.Valid {
powerState = node.PowerState.String
}

provisionState := "unknown"
if node.ProvisionState.Valid {
provisionState = node.ProvisionState.String
}

resourceClass := "unknown"
if node.ResourceClass.Valid {
resourceClass = node.ResourceClass.String
}

consoleEnabled := "false"
if node.ConsoleEnabled.Valid && node.ConsoleEnabled.Bool {
consoleEnabled = "true"
}

retired := "false"
if node.Retired.Valid && node.Retired.Bool {
retired = "true"
}

retiredReason := truncateLabel(node.RetiredReason)

name := ""
if node.Name.Valid {
name = node.Name.String
}
Comment on lines +116 to +121
Copy link

Copilot AI Feb 12, 2026

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.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

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:)


nodeUUID := node.Uuid.String

// Emit individual node metric
metric, err := prometheus.NewConstMetric(
c.nodeMetric,
prometheus.GaugeValue,
1,
nodeUUID, name, powerState, provisionState, resourceClass, maintenance, consoleEnabled, retired, retiredReason,
)
if err != nil {
c.logger.Error("failed to create node metric", "error", err)
continue
}
ch <- metric
}

return nil
}
31 changes: 31 additions & 0 deletions internal/db/ironic/db.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading