Skip to content
Merged
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
10 changes: 1 addition & 9 deletions components/api-server/pkg/rbac/user_provisioning.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,7 @@ func HasPlatformAdminRole(ctx context.Context, userID string) bool {
if userID == "" {
return false
}
v := ctx.Value(ContextJWTRolesKey)
if v == nil {
return false
}
jwtRoles, ok := v.([]string)
if !ok {
return false
}
for _, role := range jwtRoles {
for _, role := range GetJWTRolesFromContext(ctx) {
if role == "platform:admin" {
return true
}
Expand Down
15 changes: 15 additions & 0 deletions components/api-server/plugins/gateways/dao.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ type GatewayDao interface {
SetActiveSandboxCount(ctx context.Context, namespace string, count int) (resulting int, err error)

CountByPhase(ctx context.Context) (map[string]int64, error)

// SumActiveSandboxCount returns the fleet-wide sum of active_sandbox_count
// across live gateways, treating NULL as zero.
SumActiveSandboxCount(ctx context.Context) (int64, error)
}

// sandboxCountRow captures the gateway identity and count returned by the
Expand Down Expand Up @@ -230,3 +234,14 @@ func (d *sqlGatewayDao) CountByPhase(ctx context.Context) (map[string]int64, err
}
return counts, nil
}

func (d *sqlGatewayDao) SumActiveSandboxCount(ctx context.Context) (int64, error) {
g2 := (*d.sessionFactory).New(ctx)
var total int64
if err := g2.Model(&Gateway{}).
Select("COALESCE(SUM(COALESCE(active_sandbox_count, 0)), 0)").
Scan(&total).Error; err != nil {
return 0, err
}
return total, nil
}
33 changes: 33 additions & 0 deletions components/api-server/plugins/gateways/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func RegisterGatewayMetrics(dao GatewayDao) {
}

prometheus.MustRegister(newGatewayCollector(dao))
prometheus.MustRegister(newGatewayActiveSandboxesCollector(dao))
})
}

Expand Down Expand Up @@ -101,3 +102,35 @@ func (c *gatewayCollector) Collect(ch chan<- prometheus.Metric) {
}
ch <- prometheus.MustNewConstMetric(c.desc, prometheus.GaugeValue, other, gatewayPhaseOther)
}

const activeSandboxesHelp = "Total active agent sandboxes across all gateways."

type gatewayActiveSandboxesCollector struct {
dao GatewayDao
desc *prometheus.Desc
}

func newGatewayActiveSandboxesCollector(dao GatewayDao) *gatewayActiveSandboxesCollector {
return &gatewayActiveSandboxesCollector{
dao: dao,
desc: prometheus.NewDesc(
prometheus.BuildFQName(metricsNamespace, metricsSubsystem, "active_sandboxes_total"),
activeSandboxesHelp,
nil,
nil,
),
}
}

func (c *gatewayActiveSandboxesCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.desc
}

func (c *gatewayActiveSandboxesCollector) Collect(ch chan<- prometheus.Metric) {
total, err := c.dao.SumActiveSandboxCount(context.Background())
if err != nil {
ch <- prometheus.NewInvalidMetric(c.desc, err)
return
}
ch <- prometheus.MustNewConstMetric(c.desc, prometheus.GaugeValue, float64(total))
}
8 changes: 8 additions & 0 deletions components/api-server/plugins/gateways/mock_dao.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ func (d *gatewayDaoMock) CountByPhase(ctx context.Context) (map[string]int64, er
return counts, nil
}

func (d *gatewayDaoMock) SumActiveSandboxCount(ctx context.Context) (int64, error) {
var total int64
for _, gw := range d.gateways {
total += int64(derefCount(gw.ActiveSandboxCount))
}
return total, nil
}

func (d *gatewayDaoMock) findByNamespace(namespace string) *Gateway {
for _, gateway := range d.gateways {
if gateway.Namespace == namespace {
Expand Down
10 changes: 10 additions & 0 deletions components/api-server/plugins/managedClusters/dao.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package managedClusters

import (
"context"
"time"

"gorm.io/gorm/clause"

Expand All @@ -17,6 +18,7 @@ type ManagedClusterDao interface {
FindByIDs(ctx context.Context, ids []string) (ManagedClusterList, error)
All(ctx context.Context) (ManagedClusterList, error)
FindByOIDCSubject(ctx context.Context, subject string) (*ManagedCluster, error)
InventorySnapshot(ctx context.Context, evaluationTime time.Time) (*ClusterInventorySnapshot, error)
}

var _ ManagedClusterDao = &sqlManagedClusterDao{}
Expand Down Expand Up @@ -91,3 +93,11 @@ func (d *sqlManagedClusterDao) FindByOIDCSubject(ctx context.Context, subject st
}
return &managedCluster, nil
}

func (d *sqlManagedClusterDao) InventorySnapshot(ctx context.Context, evaluationTime time.Time) (*ClusterInventorySnapshot, error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

InventorySnapshot calls d.All(ctx) and buckets in Go on every Prometheus scrape. The gateway collector in this same PR aggregates in SQL (CountByPhase); doing the same here (GROUP BY status, provider, region plus a windowed created-count) would avoid loading the full table each scrape and keep the collectors consistent. Not blocking at current scale.

clusters, err := d.All(ctx)
if err != nil {
return nil, err
}
return buildClusterInventorySnapshot(clusters, evaluationTime), nil
}
85 changes: 85 additions & 0 deletions components/api-server/plugins/managedClusters/inventory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package managedClusters

import (
"strings"
"time"
)

const inventoryLookback = 30 * 24 * time.Hour

// ClusterInventoryRow is one labeled bucket in the managed cluster inventory
// aggregate exposed to Prometheus.
type ClusterInventoryRow struct {
Status string
Provider string
Region string
Count int64
}

// ClusterInventorySnapshot is the fleet-wide managed cluster inventory computed
// on each metrics scrape.
type ClusterInventorySnapshot struct {
CreatedLast30Days int64
Rows []ClusterInventoryRow
}

func inventoryBucketString(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "unknown"
}
return trimmed
}

func inventoryBucketOptional(value *string) string {
if value == nil {
return "unknown"
}
return inventoryBucketString(*value)
}

func inventoryLookbackStart(evaluationTime time.Time) time.Time {
return evaluationTime.UTC().Add(-inventoryLookback)
}

func buildClusterInventorySnapshot(
clusters ManagedClusterList,
evaluationTime time.Time,
) *ClusterInventorySnapshot {
windowStart := inventoryLookbackStart(evaluationTime)
type inventoryKey struct {
status string
provider string
region string
}
counts := make(map[inventoryKey]int64)
var createdLast30Days int64

for _, cluster := range clusters {
key := inventoryKey{
status: inventoryBucketOptional(cluster.Status),
provider: inventoryBucketString(cluster.Provider),
region: inventoryBucketOptional(cluster.Region),
}
counts[key]++

if !cluster.CreatedAt.IsZero() && !cluster.CreatedAt.Before(windowStart) {
createdLast30Days++
}
}

rows := make([]ClusterInventoryRow, 0, len(counts))
for key, count := range counts {
rows = append(rows, ClusterInventoryRow{
Status: key.status,
Provider: key.provider,
Region: key.region,
Count: count,
})
}

return &ClusterInventorySnapshot{
CreatedLast30Days: createdLast30Days,
Rows: rows,
}
}
49 changes: 49 additions & 0 deletions components/api-server/plugins/managedClusters/inventory_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package managedClusters

import (
"testing"
"time"

"github.com/openshift-online/rh-trex-ai/pkg/api"
)

func TestBuildClusterInventorySnapshot(t *testing.T) {
evaluationTime := time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC)
recentCreatedAt := evaluationTime.Add(-10 * 24 * time.Hour)
oldCreatedAt := evaluationTime.Add(-60 * 24 * time.Hour)

clusters := ManagedClusterList{
{
Meta: api.Meta{CreatedAt: recentCreatedAt},
Provider: "aws",
Region: strPtr("us-east-1"),
Status: strPtr("Ready"),
},
{
Meta: api.Meta{CreatedAt: oldCreatedAt},
Provider: "openshift",
Region: nil,
Status: strPtr("Failed"),
},
{
Meta: api.Meta{CreatedAt: recentCreatedAt},
Provider: "aws",
Region: strPtr(""),
Status: nil,
},
}

snapshot := buildClusterInventorySnapshot(clusters, evaluationTime)

if snapshot.CreatedLast30Days != 2 {
t.Fatalf("expected 2 clusters created in the last 30 days, got %d", snapshot.CreatedLast30Days)
}

if len(snapshot.Rows) != 3 {
t.Fatalf("expected 3 inventory rows, got %d", len(snapshot.Rows))
}
}

func strPtr(value string) *string {
return &value
}
91 changes: 91 additions & 0 deletions components/api-server/plugins/managedClusters/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package managedClusters

import (
"context"
"sync"
"time"

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

const (
metricsNamespace = "hypershell"
metricsSubsystem = "managed_clusters"
)

var (
managedClusterMetricsOnce sync.Once
)

// RegisterManagedClusterMetrics registers Prometheus gauges for fleet-wide
// managed cluster inventory. Safe to call multiple times.
func RegisterManagedClusterMetrics(dao ManagedClusterDao) {
managedClusterMetricsOnce.Do(func() {
prometheus.MustRegister(newManagedClusterInventoryCollector(dao))
})
}

type managedClusterInventoryCollector struct {
dao ManagedClusterDao
totalDesc *prometheus.Desc
createdDesc *prometheus.Desc
dimensionedDesc *prometheus.Desc
}

func newManagedClusterInventoryCollector(dao ManagedClusterDao) *managedClusterInventoryCollector {
return &managedClusterInventoryCollector{
dao: dao,
totalDesc: prometheus.NewDesc(
prometheus.BuildFQName(metricsNamespace, metricsSubsystem, "total"),
"Total registered managed clusters.",
nil,
nil,
),
createdDesc: prometheus.NewDesc(
prometheus.BuildFQName(metricsNamespace, metricsSubsystem, "created_last_30_days_total"),
"Managed clusters created in the last 30 days.",
nil,
nil,
),
dimensionedDesc: prometheus.NewDesc(
prometheus.BuildFQName(metricsNamespace, metricsSubsystem, "inventory_total"),
"Managed clusters by inventory status, provider, and region.",
[]string{"status", "provider", "region"},
Comment thread
kdoberst marked this conversation as resolved.
nil,
),
}
}

func (c *managedClusterInventoryCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.totalDesc
ch <- c.createdDesc
ch <- c.dimensionedDesc
}

func (c *managedClusterInventoryCollector) Collect(ch chan<- prometheus.Metric) {
snapshot, err := c.dao.InventorySnapshot(context.Background(), time.Now().UTC())
if err != nil {
ch <- prometheus.NewInvalidMetric(c.totalDesc, err)
return
}

var total int64
for _, row := range snapshot.Rows {
total += row.Count
ch <- prometheus.MustNewConstMetric(
c.dimensionedDesc,
prometheus.GaugeValue,
float64(row.Count),
row.Status,
row.Provider,
row.Region,
)
}

ch <- prometheus.MustNewConstMetric(c.totalDesc, prometheus.GaugeValue, float64(total))
ch <- prometheus.MustNewConstMetric(
c.createdDesc,
prometheus.GaugeValue,
float64(snapshot.CreatedLast30Days),
)
}
5 changes: 5 additions & 0 deletions components/api-server/plugins/managedClusters/mock_dao.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package managedClusters

import (
"context"
"time"

"gorm.io/gorm"

Expand Down Expand Up @@ -62,3 +63,7 @@ func (d *managedClusterDaoMock) FindByOIDCSubject(ctx context.Context, subject s
}
return nil, gorm.ErrRecordNotFound
}

func (d *managedClusterDaoMock) InventorySnapshot(ctx context.Context, evaluationTime time.Time) (*ClusterInventorySnapshot, error) {
return buildClusterInventorySnapshot(d.managedClusters, evaluationTime), nil
}
5 changes: 4 additions & 1 deletion components/api-server/plugins/managedClusters/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ import (
type ServiceLocator func() ManagedClusterService

func NewServiceLocator(env *environments.Env) ServiceLocator {
dao := NewManagedClusterDao(&env.Database.SessionFactory)
RegisterManagedClusterMetrics(dao)

return func() ManagedClusterService {
return NewManagedClusterService(
db.NewAdvisoryLockFactory(env.Database.SessionFactory),
NewManagedClusterDao(&env.Database.SessionFactory),
dao,
events.Service(&env.Services),
)
}
Expand Down
Loading
Loading