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()
heatDatabaseURL = kingpin.Flag(
"heat.database-url",
"Heat database connection URL (oslo.db format)",
).Envar("HEAT_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,
HeatDatabaseURL: *heatDatabaseURL,
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/heat"
"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
HeatDatabaseURL 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)
heat.RegisterCollectors(reg, cfg.HeatDatabaseURL, logger)
keystone.RegisterCollectors(reg, cfg.KeystoneDatabaseURL, logger)
magnum.RegisterCollectors(reg, cfg.MagnumDatabaseURL, logger)
manila.RegisterCollectors(reg, cfg.ManilaDatabaseURL, logger)
Expand Down
30 changes: 30 additions & 0 deletions internal/collector/heat/heat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package heat

import (
"log/slog"

"github.com/prometheus/client_golang/prometheus"
"github.com/vexxhost/openstack_database_exporter/internal/db"
)

const (
Namespace = "openstack"
Subsystem = "orchestration"
)

func RegisterCollectors(registry *prometheus.Registry, databaseURL string, logger *slog.Logger) {
if databaseURL == "" {
logger.Info("Collector not loaded", "service", "heat", "reason", "database URL not configured")
return
}

conn, err := db.Connect(databaseURL)
if err != nil {
logger.Error("Failed to connect to database", "service", "heat", "error", err)
return
}

registry.MustRegister(NewOrchestrationCollector(conn, logger))

logger.Info("Registered collectors", "service", "heat")
}
53 changes: 53 additions & 0 deletions internal/collector/heat/orchestration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package heat

import (
"database/sql"
"log/slog"

"github.com/prometheus/client_golang/prometheus"
)

var (
orchestrationUpDesc = prometheus.NewDesc(
prometheus.BuildFQName(Namespace, Subsystem, "up"),
"up",
nil,
nil,
)
)

type OrchestrationCollector struct {
db *sql.DB
logger *slog.Logger
stacksCollector *StacksCollector
}

func NewOrchestrationCollector(db *sql.DB, logger *slog.Logger) *OrchestrationCollector {
return &OrchestrationCollector{
db: db,
logger: logger,
stacksCollector: NewStacksCollector(db, logger),
}
}

func (c *OrchestrationCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- orchestrationUpDesc
c.stacksCollector.Describe(ch)
}

func (c *OrchestrationCollector) Collect(ch chan<- prometheus.Metric) {
var hasError bool

// Collect metrics from stacks collector and track errors
if err := c.stacksCollector.Collect(ch); err != nil {
c.logger.Error("stacks collector failed", "error", err)
hasError = true
}

// Emit single up metric based on overall success
upValue := float64(1)
if hasError {
upValue = float64(0)
}
ch <- prometheus.MustNewConstMetric(orchestrationUpDesc, prometheus.GaugeValue, upValue)
}
85 changes: 85 additions & 0 deletions internal/collector/heat/orchestration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package heat

import (
"database/sql"
"log/slog"
"regexp"
"testing"

"github.com/DATA-DOG/go-sqlmock"
heatdb "github.com/vexxhost/openstack_database_exporter/internal/db/heat"
"github.com/vexxhost/openstack_database_exporter/internal/testutil"
)

func TestOrchestrationCollector(t *testing.T) {
tests := []testutil.CollectorTestCase{
{
Name: "successful collection with stacks working",
SetupMock: func(mock sqlmock.Sqlmock) {
rows := sqlmock.NewRows([]string{
"id", "name", "status", "action", "tenant", "created_at", "updated_at", "deleted_at", "nested_depth", "disable_rollback",
}).AddRow(
"stack-1", "test-stack", "CREATE_COMPLETE", "CREATE", "project-1", nil, nil, nil, 0, false,
)

mock.ExpectQuery(regexp.QuoteMeta(heatdb.GetStackMetrics)).WillReturnRows(rows)
},
ExpectedMetrics: `# HELP openstack_orchestration_stack_status stack_status
# TYPE openstack_orchestration_stack_status gauge
openstack_orchestration_stack_status{action="CREATE",id="stack-1",name="test-stack",project_id="project-1",status="CREATE_COMPLETE"} 5
# HELP openstack_orchestration_stack_status_counter stack_status_counter
# TYPE openstack_orchestration_stack_status_counter gauge
openstack_orchestration_stack_status_counter{status="ADOPT_COMPLETE"} 0
openstack_orchestration_stack_status_counter{status="ADOPT_FAILED"} 0
openstack_orchestration_stack_status_counter{status="ADOPT_IN_PROGRESS"} 0
openstack_orchestration_stack_status_counter{status="CHECK_COMPLETE"} 0
openstack_orchestration_stack_status_counter{status="CHECK_FAILED"} 0
openstack_orchestration_stack_status_counter{status="CHECK_IN_PROGRESS"} 0
openstack_orchestration_stack_status_counter{status="CREATE_COMPLETE"} 1
openstack_orchestration_stack_status_counter{status="CREATE_FAILED"} 0
openstack_orchestration_stack_status_counter{status="CREATE_IN_PROGRESS"} 0
openstack_orchestration_stack_status_counter{status="DELETE_COMPLETE"} 0
openstack_orchestration_stack_status_counter{status="DELETE_FAILED"} 0
openstack_orchestration_stack_status_counter{status="DELETE_IN_PROGRESS"} 0
openstack_orchestration_stack_status_counter{status="INIT_COMPLETE"} 0
openstack_orchestration_stack_status_counter{status="INIT_FAILED"} 0
openstack_orchestration_stack_status_counter{status="INIT_IN_PROGRESS"} 0
openstack_orchestration_stack_status_counter{status="RESUME_COMPLETE"} 0
openstack_orchestration_stack_status_counter{status="RESUME_FAILED"} 0
openstack_orchestration_stack_status_counter{status="RESUME_IN_PROGRESS"} 0
openstack_orchestration_stack_status_counter{status="ROLLBACK_COMPLETE"} 0
openstack_orchestration_stack_status_counter{status="ROLLBACK_FAILED"} 0
openstack_orchestration_stack_status_counter{status="ROLLBACK_IN_PROGRESS"} 0
openstack_orchestration_stack_status_counter{status="SNAPSHOT_COMPLETE"} 0
openstack_orchestration_stack_status_counter{status="SNAPSHOT_FAILED"} 0
openstack_orchestration_stack_status_counter{status="SNAPSHOT_IN_PROGRESS"} 0
openstack_orchestration_stack_status_counter{status="SUSPEND_COMPLETE"} 0
openstack_orchestration_stack_status_counter{status="SUSPEND_FAILED"} 0
openstack_orchestration_stack_status_counter{status="SUSPEND_IN_PROGRESS"} 0
openstack_orchestration_stack_status_counter{status="UPDATE_COMPLETE"} 0
openstack_orchestration_stack_status_counter{status="UPDATE_FAILED"} 0
openstack_orchestration_stack_status_counter{status="UPDATE_IN_PROGRESS"} 0
# HELP openstack_orchestration_total_stacks total_stacks
# TYPE openstack_orchestration_total_stacks gauge
openstack_orchestration_total_stacks 1
# HELP openstack_orchestration_up up
# TYPE openstack_orchestration_up gauge
openstack_orchestration_up 1
`,
},
{
Name: "stacks collector fails, up metric should be 0",
SetupMock: func(mock sqlmock.Sqlmock) {
mock.ExpectQuery(regexp.QuoteMeta(heatdb.GetStackMetrics)).WillReturnError(sql.ErrConnDone)
},
ExpectedMetrics: `# HELP openstack_orchestration_up up
# TYPE openstack_orchestration_up gauge
openstack_orchestration_up 0
`,
},
}

testutil.RunCollectorTests(t, tests, func(db *sql.DB, logger *slog.Logger) *OrchestrationCollector {
return NewOrchestrationCollector(db, logger)
})
}
162 changes: 162 additions & 0 deletions internal/collector/heat/stacks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package heat

import (
"context"
"database/sql"
"log/slog"

"github.com/prometheus/client_golang/prometheus"
heatdb "github.com/vexxhost/openstack_database_exporter/internal/db/heat"
)

var (
// Known stack statuses from the original openstack-exporter
knownStackStatuses = []string{
"INIT_IN_PROGRESS",
"INIT_FAILED",
"INIT_COMPLETE",
"CREATE_IN_PROGRESS",
"CREATE_FAILED",
"CREATE_COMPLETE",
"DELETE_IN_PROGRESS",
"DELETE_FAILED",
"DELETE_COMPLETE",
"UPDATE_IN_PROGRESS",
"UPDATE_FAILED",
"UPDATE_COMPLETE",
"ROLLBACK_IN_PROGRESS",
"ROLLBACK_FAILED",
"ROLLBACK_COMPLETE",
"SUSPEND_IN_PROGRESS",
"SUSPEND_FAILED",
"SUSPEND_COMPLETE",
"RESUME_IN_PROGRESS",
"RESUME_FAILED",
"RESUME_COMPLETE",
"ADOPT_IN_PROGRESS",
"ADOPT_FAILED",
"ADOPT_COMPLETE",
"SNAPSHOT_IN_PROGRESS",
"SNAPSHOT_FAILED",
"SNAPSHOT_COMPLETE",
"CHECK_IN_PROGRESS",
"CHECK_FAILED",
"CHECK_COMPLETE",
}

stackStatusDesc = prometheus.NewDesc(
prometheus.BuildFQName(Namespace, Subsystem, "stack_status"),
"stack_status",
[]string{
"id",
"name",
"project_id",
"status",
"action",
},
nil,
)

stackStatusCounterDesc = prometheus.NewDesc(
prometheus.BuildFQName(Namespace, Subsystem, "stack_status_counter"),
"stack_status_counter",
[]string{
"status",
},
nil,
)

totalStacksDesc = prometheus.NewDesc(
prometheus.BuildFQName(Namespace, Subsystem, "total_stacks"),
"total_stacks",
nil,
nil,
)
)

type StacksCollector struct {
db *sql.DB
queries *heatdb.Queries
logger *slog.Logger
}

func NewStacksCollector(db *sql.DB, logger *slog.Logger) *StacksCollector {
return &StacksCollector{
db: db,
queries: heatdb.New(db),
logger: logger.With(
"namespace", Namespace,
"subsystem", Subsystem,
"collector", "stacks",
),
}
}

func (c *StacksCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- stackStatusDesc
ch <- stackStatusCounterDesc
ch <- totalStacksDesc
}

func (c *StacksCollector) Collect(ch chan<- prometheus.Metric) error {
ctx := context.Background()

stacks, err := c.queries.GetStackMetrics(ctx)
Comment on lines +101 to +104
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.

Using context.Background() means the DB query cannot be cancelled and has no upper bound on runtime during a scrape. Consider using context.WithTimeout (with a reasonable scrape-aligned timeout) so a slow/bad DB doesn’t hang collection.

Copilot uses AI. Check for mistakes.
if err != nil {
c.logger.Error("failed to query stacks", "error", err)
return err
}

// Initialize status counters
stackStatusCounter := make(map[string]int, len(knownStackStatuses))
for _, status := range knownStackStatuses {
stackStatusCounter[status] = 0
}
Comment on lines +110 to +114
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.

Emitting stack_status_counter by iterating over a map produces non-deterministic metric output order, which can make scrapes and unit tests flaky. Prefer emitting counters in a stable order (e.g., iterate over knownStackStatuses and read stackStatusCounter[status]).

Copilot uses AI. Check for mistakes.

// total_stacks count
ch <- prometheus.MustNewConstMetric(
totalStacksDesc,
prometheus.GaugeValue,
float64(len(stacks)),
)

// Individual stack status metrics and count status occurrences
for _, stack := range stacks {
// Count status occurrences
stackStatusCounter[stack.Status]++
Comment on lines +125 to +126
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.

Incrementing stackStatusCounter[stack.Status] will create new entries for unexpected/empty statuses, which then get exported as additional time series (and also impacts output ordering). If you only want the known statuses, guard the increment by checking membership in knownStackStatuses (or normalize unknowns into a single label such as UNKNOWN).

Suggested change
// Count status occurrences
stackStatusCounter[stack.Status]++
// Count status occurrences only for known statuses
if _, ok := stackStatusCounter[stack.Status]; ok {
stackStatusCounter[stack.Status]++
}

Copilot uses AI. Check for mistakes.

// stack_status metric
statusValue := mapStackStatusValue(stack.Status)
ch <- prometheus.MustNewConstMetric(
stackStatusDesc,
prometheus.GaugeValue,
float64(statusValue),
stack.ID,
stack.Name,
stack.Tenant,
stack.Status,
stack.Action,
)
}

// Stack status counter metrics
for status, count := range stackStatusCounter {
ch <- prometheus.MustNewConstMetric(
stackStatusCounterDesc,
prometheus.GaugeValue,
float64(count),
status,
)
}
Comment on lines +142 to +150
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.

Emitting stack_status_counter by iterating over a map produces non-deterministic metric output order, which can make scrapes and unit tests flaky. Prefer emitting counters in a stable order (e.g., iterate over knownStackStatuses and read stackStatusCounter[status]).

Copilot uses AI. Check for mistakes.

return nil
}

func mapStackStatusValue(status string) int {
for idx, s := range knownStackStatuses {
if status == s {
return idx
}
}
return -1
}
Comment on lines +155 to +162
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.

mapStackStatusValue performs a linear scan of knownStackStatuses for every stack. For large numbers of stacks this becomes unnecessary overhead; consider building a map[string]int (status -> value) once (e.g., in init() or as a package-level var) and doing O(1) lookups.

Copilot uses AI. Check for mistakes.
Loading
Loading