diff --git a/cmd/openstack-database-exporter/main.go b/cmd/openstack-database-exporter/main.go index 9fbd997..c27c0cd 100644 --- a/cmd/openstack-database-exporter/main.go +++ b/cmd/openstack-database-exporter/main.go @@ -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)", @@ -73,6 +77,7 @@ func main() { reg := collector.NewRegistry(collector.Config{ CinderDatabaseURL: *cinderDatabaseURL, GlanceDatabaseURL: *glanceDatabaseURL, + IronicDatabaseURL: *ironicDatabaseURL, KeystoneDatabaseURL: *keystoneDatabaseURL, MagnumDatabaseURL: *magnumDatabaseURL, ManilaDatabaseURL: *manilaDatabaseURL, diff --git a/internal/collector/collector.go b/internal/collector/collector.go index e05c652..5a37f83 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -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" @@ -22,6 +23,7 @@ const ( type Config struct { CinderDatabaseURL string GlanceDatabaseURL string + IronicDatabaseURL string KeystoneDatabaseURL string MagnumDatabaseURL string ManilaDatabaseURL string @@ -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) diff --git a/internal/collector/ironic/baremetal.go b/internal/collector/ironic/baremetal.go new file mode 100644 index 0000000..ad7a05f --- /dev/null +++ b/internal/collector/ironic/baremetal.go @@ -0,0 +1,86 @@ +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 { + db *sql.DB + 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{ + db: db, + 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() + up := float64(1) + + // Test database connectivity by running a simple query + _, err := c.queries.GetNodeMetrics(ctx) + if err != nil { + c.logger.Error("failed to query Ironic database", "error", err) + up = 0 + } + + // Emit up metric + upMetric, err := prometheus.NewConstMetric( + c.upMetric, + prometheus.GaugeValue, + up, + ) + if err != nil { + c.logger.Error("failed to create up metric", "error", err) + } else { + ch <- upMetric + } + + // Only collect from sub-collectors if we're up + if up == 1 { + // Collect nodes metrics + if err := c.nodesCollector.Collect(ch); err != nil { + c.logger.Error("nodes collector failed", "error", err) + } + } +} diff --git a/internal/collector/ironic/baremetal_test.go b/internal/collector/ironic/baremetal_test.go new file mode 100644 index 0000000..55d43d9 --- /dev/null +++ b/internal/collector/ironic/baremetal_test.go @@ -0,0 +1,78 @@ +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) { + // Mock the up check query + 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) + + // Mock the nodes collector query + rows2 := 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(rows2) + }, + 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 the up check query to fail + 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: "nodes collector failure but service up", + SetupMock: func(mock sqlmock.Sqlmock) { + // Mock the up check query to succeed + rows := sqlmock.NewRows([]string{ + "uuid", "name", "power_state", "provision_state", "maintenance", + "resource_class", "console_enabled", "retired", "retired_reason", + }) + mock.ExpectQuery(regexp.QuoteMeta(ironicdb.GetNodeMetrics)).WillReturnRows(rows) + + // Mock the nodes collector query to fail + 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 1 +`, + }, + } + + testutil.RunCollectorTests(t, tests, NewBaremetalCollector) +} diff --git a/internal/collector/ironic/ironic.go b/internal/collector/ironic/ironic.go new file mode 100644 index 0000000..a89aa70 --- /dev/null +++ b/internal/collector/ironic/ironic.go @@ -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") +} diff --git a/internal/collector/ironic/nodes.go b/internal/collector/ironic/nodes.go new file mode 100644 index 0000000..aecf55d --- /dev/null +++ b/internal/collector/ironic/nodes.go @@ -0,0 +1,119 @@ +package ironic + +import ( + "context" + "database/sql" + "log/slog" + + "github.com/prometheus/client_golang/prometheus" + ironicdb "github.com/vexxhost/openstack_database_exporter/internal/db/ironic" +) + +// NodesCollector collects metrics about Ironic nodes +type NodesCollector struct { + db *sql.DB + queries *ironicdb.Queries + logger *slog.Logger + + // Individual node metrics + nodeMetric *prometheus.Desc +} + +// NewNodesCollector creates a new NodesCollector +func NewNodesCollector(db *sql.DB, logger *slog.Logger) *NodesCollector { + return &NodesCollector{ + db: db, + 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", + "resource_class", "maintenance", "console_enabled", "retired", "retired_reason", + }, + nil, + ), + } +} + +// Describe implements prometheus.Collector +func (c *NodesCollector) Describe(ch chan<- *prometheus.Desc) { + ch <- c.nodeMetric +} + +// Collect implements prometheus.Collector +func (c *NodesCollector) Collect(ch chan<- prometheus.Metric) error { + ctx := context.Background() + + nodes, err := c.queries.GetNodeMetrics(ctx) + if err != nil { + c.logger.Error("failed to get node metrics", "error", err) + return err + } + + for _, node := range nodes { + // 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 := node.RetiredReason + + name := "" + if node.Name.Valid { + name = node.Name.String + } + + nodeUUID := "" + if node.Uuid.Valid { + 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 +} diff --git a/internal/db/ironic/db.go b/internal/db/ironic/db.go new file mode 100644 index 0000000..09cd627 --- /dev/null +++ b/internal/db/ironic/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 + +package ironic + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/db/ironic/models.go b/internal/db/ironic/models.go new file mode 100644 index 0000000..ff7d4e9 --- /dev/null +++ b/internal/db/ironic/models.go @@ -0,0 +1,74 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 + +package ironic + +import ( + "database/sql" +) + +type Node struct { + CreatedAt sql.NullTime + UpdatedAt sql.NullTime + ID int32 + Uuid sql.NullString + InstanceUuid sql.NullString + ChassisID sql.NullInt32 + PowerState sql.NullString + TargetPowerState sql.NullString + ProvisionState sql.NullString + TargetProvisionState sql.NullString + LastError sql.NullString + Properties sql.NullString + Driver sql.NullString + DriverInfo sql.NullString + Reservation sql.NullString + Maintenance sql.NullBool + Extra sql.NullString + ProvisionUpdatedAt sql.NullTime + ConsoleEnabled sql.NullBool + InstanceInfo sql.NullString + ConductorAffinity sql.NullInt32 + MaintenanceReason sql.NullString + DriverInternalInfo sql.NullString + Name sql.NullString + InspectionStartedAt sql.NullTime + InspectionFinishedAt sql.NullTime + CleanStep sql.NullString + RaidConfig sql.NullString + TargetRaidConfig sql.NullString + NetworkInterface sql.NullString + ResourceClass sql.NullString + BootInterface sql.NullString + ConsoleInterface sql.NullString + DeployInterface sql.NullString + InspectInterface sql.NullString + ManagementInterface sql.NullString + PowerInterface sql.NullString + RaidInterface sql.NullString + VendorInterface sql.NullString + StorageInterface sql.NullString + Version sql.NullString + RescueInterface sql.NullString + BiosInterface sql.NullString + Fault sql.NullString + DeployStep sql.NullString + ConductorGroup string + AutomatedClean sql.NullBool + Protected bool + ProtectedReason sql.NullString + Owner sql.NullString + AllocationID sql.NullInt32 + Description sql.NullString + Retired sql.NullBool + RetiredReason sql.NullString + Lessee sql.NullString + NetworkData sql.NullString + BootMode sql.NullString + SecureBoot sql.NullBool + Shard sql.NullString + ParentNode sql.NullString + FirmwareInterface sql.NullString + ServiceStep sql.NullString +} diff --git a/internal/db/ironic/queries.sql.go b/internal/db/ironic/queries.sql.go new file mode 100644 index 0000000..9f6afee --- /dev/null +++ b/internal/db/ironic/queries.sql.go @@ -0,0 +1,72 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: queries.sql + +package ironic + +import ( + "context" + "database/sql" +) + +const GetNodeMetrics = `-- name: GetNodeMetrics :many +SELECT + uuid, + name, + power_state, + provision_state, + maintenance, + resource_class, + console_enabled, + retired, + COALESCE(retired_reason, '') as retired_reason +FROM nodes +WHERE provision_state != 'deleted' +ORDER BY created_at +` + +type GetNodeMetricsRow struct { + Uuid sql.NullString + Name sql.NullString + PowerState sql.NullString + ProvisionState sql.NullString + Maintenance sql.NullBool + ResourceClass sql.NullString + ConsoleEnabled sql.NullBool + Retired sql.NullBool + RetiredReason string +} + +func (q *Queries) GetNodeMetrics(ctx context.Context) ([]GetNodeMetricsRow, error) { + rows, err := q.db.QueryContext(ctx, GetNodeMetrics) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetNodeMetricsRow + for rows.Next() { + var i GetNodeMetricsRow + if err := rows.Scan( + &i.Uuid, + &i.Name, + &i.PowerState, + &i.ProvisionState, + &i.Maintenance, + &i.ResourceClass, + &i.ConsoleEnabled, + &i.Retired, + &i.RetiredReason, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/sql/ironic/queries.sql b/sql/ironic/queries.sql new file mode 100644 index 0000000..7a6f628 --- /dev/null +++ b/sql/ironic/queries.sql @@ -0,0 +1,14 @@ +-- name: GetNodeMetrics :many +SELECT + uuid, + name, + power_state, + provision_state, + maintenance, + resource_class, + console_enabled, + retired, + COALESCE(retired_reason, '') as retired_reason +FROM nodes +WHERE provision_state != 'deleted' +ORDER BY created_at; diff --git a/sql/ironic/schema.sql b/sql/ironic/schema.sql new file mode 100644 index 0000000..b76ec37 --- /dev/null +++ b/sql/ironic/schema.sql @@ -0,0 +1,77 @@ +CREATE TABLE + `nodes` ( + `created_at` DATETIME, + `updated_at` DATETIME, + `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `uuid` VARCHAR(36) UNIQUE, + `instance_uuid` VARCHAR(36) UNIQUE, + `chassis_id` INT, + `power_state` VARCHAR(15), + `target_power_state` VARCHAR(15), + `provision_state` VARCHAR(15), + `target_provision_state` VARCHAR(15), + `last_error` TEXT, + `properties` TEXT, + `driver` VARCHAR(255), + `driver_info` TEXT, + `reservation` VARCHAR(255), + `maintenance` TINYINT(1), + `extra` TEXT, + `provision_updated_at` DATETIME, + `console_enabled` TINYINT(1), + `instance_info` LONGTEXT, + `conductor_affinity` INT, + `maintenance_reason` TEXT, + `driver_internal_info` TEXT, + `name` VARCHAR(255) UNIQUE, + `inspection_started_at` DATETIME, + `inspection_finished_at` DATETIME, + `clean_step` TEXT, + `raid_config` TEXT, + `target_raid_config` TEXT, + `network_interface` VARCHAR(255), + `resource_class` VARCHAR(80), + `boot_interface` VARCHAR(255), + `console_interface` VARCHAR(255), + `deploy_interface` VARCHAR(255), + `inspect_interface` VARCHAR(255), + `management_interface` VARCHAR(255), + `power_interface` VARCHAR(255), + `raid_interface` VARCHAR(255), + `vendor_interface` VARCHAR(255), + `storage_interface` VARCHAR(255), + `version` VARCHAR(15), + `rescue_interface` VARCHAR(255), + `bios_interface` VARCHAR(255), + `fault` VARCHAR(255), + `deploy_step` TEXT, + `conductor_group` VARCHAR(255) NOT NULL DEFAULT '', + `automated_clean` TINYINT(1), + `protected` TINYINT(1) NOT NULL DEFAULT '0', + `protected_reason` TEXT, + `owner` VARCHAR(255), + `allocation_id` INT, + `description` TEXT, + `retired` TINYINT(1) DEFAULT '0', + `retired_reason` TEXT, + `lessee` VARCHAR(255), + `network_data` TEXT, + `boot_mode` VARCHAR(16), + `secure_boot` TINYINT(1), + `shard` VARCHAR(255), + `parent_node` VARCHAR(36), + `firmware_interface` VARCHAR(255), + `service_step` TEXT, + INDEX `chassis_id_idx` (`chassis_id`), + INDEX `conductor_affinity_idx` (`conductor_affinity`), + INDEX `allocation_id_idx` (`allocation_id`), + INDEX `reservation_idx` (`reservation`), + INDEX `driver_idx` (`driver`), + INDEX `owner_idx` (`owner`), + INDEX `lessee_idx` (`lessee`), + INDEX `provision_state_idx` (`provision_state`), + INDEX `conductor_group_idx` (`conductor_group`), + INDEX `resource_class_idx` (`resource_class`), + INDEX `shard_idx` (`shard`), + INDEX `parent_node_idx` (`parent_node`) + ); diff --git a/sqlc.yaml b/sqlc.yaml index 70b81a9..d1e6a0e 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -65,3 +65,11 @@ sql: package: "placement" out: "internal/db/placement" emit_exported_queries: true + - engine: "mysql" + schema: "sql/ironic/schema.sql" + queries: "sql/ironic/queries.sql" + gen: + go: + package: "ironic" + out: "internal/db/ironic" + emit_exported_queries: true