From fcad766b3bcbb753966fab0e3d22316826a0b7a0 Mon Sep 17 00:00:00 2001 From: Yury Kovalev Date: Fri, 7 Aug 2026 16:47:08 +0200 Subject: [PATCH 1/3] ROX-35081: Add instance report admin command Add `fleet-manager admin central report` command that fetches all centrals from the fleet-manager API and generates a summary report. Co-Authored-By: Claude Opus 4.6 --- cmd/acsfleetctl/main.go | 2 - cmd/fleet-manager/main.go | 40 ++-- .../pkg/api/admin/private/api/openapi.yaml | 2 + .../pkg/api/admin/private/model_central.go | 39 +-- .../central/pkg/cmd/admin/centrals/cmd.go | 10 +- .../central/pkg/cmd/admin/centrals/list.go | 2 +- .../central/pkg/cmd/admin/centrals/report.go | 226 ++++++++++++++++++ .../pkg/cmd/admin/centrals/report_test.go | 193 +++++++++++++++ internal/central/pkg/cmd/admin/cmd.go | 45 +++- .../pkg/cmd/fleetmanagerclient/client.go | 73 ++---- .../central/pkg/presenters/admin_central.go | 37 +-- openapi/fleet-manager-private-admin.yaml | 2 + 12 files changed, 554 insertions(+), 117 deletions(-) create mode 100644 internal/central/pkg/cmd/admin/centrals/report.go create mode 100644 internal/central/pkg/cmd/admin/centrals/report_test.go diff --git a/cmd/acsfleetctl/main.go b/cmd/acsfleetctl/main.go index d6bf859095..df3cef2808 100644 --- a/cmd/acsfleetctl/main.go +++ b/cmd/acsfleetctl/main.go @@ -5,7 +5,6 @@ import ( "os" "github.com/spf13/cobra" - "github.com/stackrox/acs-fleet-manager/internal/central/pkg/cmd/admin" "github.com/stackrox/acs-fleet-manager/internal/central/pkg/cmd/centrals" gitopsCmd "github.com/stackrox/acs-fleet-manager/internal/central/pkg/gitops/cmd" ) @@ -26,6 +25,5 @@ func main() { func setupSubCommands(rootCmd *cobra.Command) { rootCmd.AddCommand(centrals.NewCentralsCommand()) - rootCmd.AddCommand(admin.NewAdminCommand()) rootCmd.AddCommand(gitopsCmd.NewGitOpsCommand()) } diff --git a/cmd/fleet-manager/main.go b/cmd/fleet-manager/main.go index 13db6dcb49..2b8976f546 100644 --- a/cmd/fleet-manager/main.go +++ b/cmd/fleet-manager/main.go @@ -3,7 +3,10 @@ package main import ( "flag" + "fmt" + "os" + "github.com/stackrox/acs-fleet-manager/internal/central/pkg/cmd/admin" "github.com/stackrox/acs-fleet-manager/pkg/cmd/migrate" "github.com/stackrox/acs-fleet-manager/pkg/cmd/serve" @@ -19,8 +22,6 @@ func main() { // parsed. _ = flag.CommandLine.Parse([]string{}) - // pflag.CommandLine.AddGoFlagSet(flag.CommandLine) - // Always log to stderr by default if err := flag.Set("logtostderr", "true"); err != nil { glog.Infof("Unable to set logtostderr to true") @@ -30,32 +31,31 @@ func main() { central.ConfigProviders(), ) if err != nil { - glog.Fatalf("error initializing: %v", err) + _, _ = fmt.Fprintf(os.Stderr, "error initializing: %v\n", err) + os.Exit(1) } - defer env.Cleanup() + rootCmd := rootCommand(env) + if err := env.AddFlags(rootCmd.PersistentFlags()); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "unable to add global flags: %v\n", err) + os.Exit(1) + } + err = rootCmd.Execute() + env.Cleanup() + if err != nil { + os.Exit(1) + } +} + +func rootCommand(env *environments.Env) *cobra.Command { rootCmd := &cobra.Command{ Use: "fleet-manager", Long: "fleet-manager is a service that exposes a Rest API to manage ACS Central instances.", } - err = env.AddFlags(rootCmd.PersistentFlags()) - if err != nil { - glog.Fatalf("Unable to add global flags: %s", err.Error()) - } - + rootCmd.AddCommand(admin.NewAdminCommand()) rootCmd.AddCommand(migrate.NewMigrateCommand(env)) rootCmd.AddCommand(serve.NewServeCommand(env)) - // Unsupported CLI commands. Eventually some of them can be removed. - // rootCmd.AddCommand(cluster.NewClusterCommand(env)) - // rootCmd.AddCommand(cloudprovider.NewCloudProviderCommand(env)) - // rootCmd.AddCommand(errors.NewErrorsCommand(env)) - - if err := rootCmd.Execute(); err != nil { - glog.Fatalf("error running command: %v", err) - } - if err != nil { - glog.Fatalf("Unable to initialize environment: %s", err.Error()) - } + return rootCmd } diff --git a/internal/central/pkg/api/admin/private/api/openapi.yaml b/internal/central/pkg/api/admin/private/api/openapi.yaml index 11b2d59c4c..6244fc5de6 100644 --- a/internal/central/pkg/api/admin/private/api/openapi.yaml +++ b/internal/central/pkg/api/admin/private/api/openapi.yaml @@ -1077,6 +1077,8 @@ components: type: string organisation_id: type: string + organisation_name: + type: string subscription_id: type: string owner_account_id: diff --git a/internal/central/pkg/api/admin/private/model_central.go b/internal/central/pkg/api/admin/private/model_central.go index a5445e1db6..8bc8ecb954 100644 --- a/internal/central/pkg/api/admin/private/model_central.go +++ b/internal/central/pkg/api/admin/private/model_central.go @@ -25,23 +25,24 @@ type Central struct { CloudProvider string `json:"cloud_provider,omitempty"` MultiAz bool `json:"multi_az"` // Values will be regions of specific cloud provider. For example: us-east-1 for AWS - Region string `json:"region,omitempty"` - Owner string `json:"owner,omitempty"` - Name string `json:"name,omitempty"` - Host string `json:"host,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` - ExpiredAt *time.Time `json:"expired_at,omitempty"` - FailedReason string `json:"failed_reason,omitempty"` - OrganisationId string `json:"organisation_id,omitempty"` - SubscriptionId string `json:"subscription_id,omitempty"` - OwnerAccountId string `json:"owner_account_id,omitempty"` - AccountNumber string `json:"account_number,omitempty"` - InstanceType string `json:"instance_type,omitempty"` - QuotaType string `json:"quota_type,omitempty"` - Routes []CentralAllOfRoutes `json:"routes,omitempty"` - RoutesCreated bool `json:"routes_created,omitempty"` - ClusterId string `json:"cluster_id,omitempty"` - Namespace string `json:"namespace,omitempty"` - Traits []string `json:"traits,omitempty"` + Region string `json:"region,omitempty"` + Owner string `json:"owner,omitempty"` + Name string `json:"name,omitempty"` + Host string `json:"host,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + ExpiredAt *time.Time `json:"expired_at,omitempty"` + FailedReason string `json:"failed_reason,omitempty"` + OrganisationId string `json:"organisation_id,omitempty"` + OrganisationName string `json:"organisation_name,omitempty"` + SubscriptionId string `json:"subscription_id,omitempty"` + OwnerAccountId string `json:"owner_account_id,omitempty"` + AccountNumber string `json:"account_number,omitempty"` + InstanceType string `json:"instance_type,omitempty"` + QuotaType string `json:"quota_type,omitempty"` + Routes []CentralAllOfRoutes `json:"routes,omitempty"` + RoutesCreated bool `json:"routes_created,omitempty"` + ClusterId string `json:"cluster_id,omitempty"` + Namespace string `json:"namespace,omitempty"` + Traits []string `json:"traits,omitempty"` } diff --git a/internal/central/pkg/cmd/admin/centrals/cmd.go b/internal/central/pkg/cmd/admin/centrals/cmd.go index 0cc754c7fd..84f78f2e3b 100644 --- a/internal/central/pkg/cmd/admin/centrals/cmd.go +++ b/internal/central/pkg/cmd/admin/centrals/cmd.go @@ -10,14 +10,14 @@ const ( // NewAdminCentralsCommand creates a new admin central command. func NewAdminCentralsCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "centrals", - Aliases: []string{"central"}, - Short: "Perform admin central API calls.", - Long: "Perform admin central API calls.", - PersistentPreRun: func(cmd *cobra.Command, args []string) {}, + Use: "centrals", + Aliases: []string{"central"}, + Short: "Perform admin central API calls.", + Long: "Perform admin central API calls.", } cmd.AddCommand( NewAdminCentralsListCommand(), + NewAdminReportCommand(), ) return cmd diff --git a/internal/central/pkg/cmd/admin/centrals/list.go b/internal/central/pkg/cmd/admin/centrals/list.go index db66a82818..612996b87d 100644 --- a/internal/central/pkg/cmd/admin/centrals/list.go +++ b/internal/central/pkg/cmd/admin/centrals/list.go @@ -19,7 +19,7 @@ func NewAdminCentralsListCommand() *cobra.Command { Short: "lists all managed central requests", Long: "lists all managed central requests", Run: func(cmd *cobra.Command, args []string) { - runList(fleetmanagerclient.AuthenticatedClientWithRHOASToken(cmd.Context()), cmd, args) + runList(fleetmanagerclient.ClientFromContext(cmd.Context()), cmd, args) }, } return cmd diff --git a/internal/central/pkg/cmd/admin/centrals/report.go b/internal/central/pkg/cmd/admin/centrals/report.go new file mode 100644 index 0000000000..2b681dc71c --- /dev/null +++ b/internal/central/pkg/cmd/admin/centrals/report.go @@ -0,0 +1,226 @@ +package centrals + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "text/tabwriter" + "time" + + "os" + + "github.com/antihax/optional" + "github.com/golang/glog" + + "github.com/spf13/cobra" + "github.com/stackrox/acs-fleet-manager/internal/central/constants" + adminAPI "github.com/stackrox/acs-fleet-manager/internal/central/pkg/api/admin/private" + "github.com/stackrox/acs-fleet-manager/internal/central/pkg/cmd/fleetmanagerclient" +) + +var excludedStatuses = []string{ + constants.CentralRequestStatusFailed.String(), +} + +// NewAdminReportCommand creates the admin report subcommand. +func NewAdminReportCommand() *cobra.Command { + return &cobra.Command{ + Use: "report", + Short: "Print ACS instance report in Slack mrkdwn format", + Long: "Print ACS instance report in Slack mrkdwn format.", + Run: func(cmd *cobra.Command, args []string) { + client := fleetmanagerclient.ClientFromContext(cmd.Context()) + if err := runReport(cmd.Context(), client.AdminAPI(), os.Stdout); err != nil { + glog.Errorf("instance report failed: %v", err) + os.Exit(1) + } + }, + } +} + +// reportAPI is the subset of the fleet manager admin API needed for the report. +type reportAPI interface { + GetCentrals(ctx context.Context, opts *adminAPI.GetCentralsOpts) (adminAPI.CentralList, *http.Response, error) +} + +func fetchAllCentrals(ctx context.Context, api reportAPI, search string) ([]adminAPI.Central, error) { + const pageSize = "100" + var all []adminAPI.Central + + for page := 1; ; page++ { + opts := &adminAPI.GetCentralsOpts{ + Page: optional.NewString(fmt.Sprintf("%d", page)), + Size: optional.NewString(pageSize), + OrderBy: optional.NewString("created_at desc"), + } + if search != "" { + opts.Search = optional.NewString(search) + } + + list, _, err := api.GetCentrals(ctx, opts) + if err != nil { + return nil, fmt.Errorf("fetching centrals (page %d): %w", page, err) + } + + all = append(all, list.Items...) + + if int32(len(all)) >= list.Total { + break + } + } + return all, nil +} + +func buildStatusFilter() string { + var parts []string + for _, s := range excludedStatuses { + parts = append(parts, fmt.Sprintf("status <> %s", s)) + } + return strings.Join(parts, " and ") +} + +func runReport(ctx context.Context, api reportAPI, w io.Writer) error { + statusFilter := buildStatusFilter() + + allCentrals, err := fetchAllCentrals(ctx, api, statusFilter) + if err != nil { + return fmt.Errorf("fetching centrals: %w", err) + } + + sevenDaysAgo := time.Now().UTC().Add(-7 * 24 * time.Hour) + + var naInstances, euInstances, evalInstances, expiredInstances []adminAPI.Central + + for i := range allCentrals { + c := &allCentrals[i] + + if strings.HasPrefix(c.Name, "probe-") { + continue + } + + if strings.HasPrefix(c.Region, "us-") { + if !c.CreatedAt.Before(sevenDaysAgo) { + naInstances = append(naInstances, *c) + } + } + + if strings.HasPrefix(c.Region, "eu-") { + if !c.CreatedAt.Before(sevenDaysAgo) { + euInstances = append(euInstances, *c) + } + } + + if c.InstanceType == "eval" { + evalInstances = append(evalInstances, *c) + } + + if c.ExpiredAt != nil { + expiredInstances = append(expiredInstances, *c) + } + } + + allFailedCentrals, err := fetchAllCentrals(ctx, api, "status = failed") + if err != nil { + return fmt.Errorf("fetching failed centrals: %w", err) + } + var failedCentrals []adminAPI.Central + for i := range allFailedCentrals { + if !strings.HasPrefix(allFailedCentrals[i].Name, "probe-") { + failedCentrals = append(failedCentrals, allFailedCentrals[i]) + } + } + + today := time.Now().UTC().Format("2006-01-02") + fmt.Fprintf(w, "*Instance Report* (%s)\n", today) + fmt.Fprintln(w, "I live in , you can get your own copy by running `./fleet-manager admin central report` there.") + + regionHeaders := []string{"ID", "Name", "Org", "Owner", "Created", "Type/Quota", "Status"} + writeSection(w, "All *new* instances in North America in the past *7 days*:", naInstances, regionHeaders, regionRow) + writeSection(w, "All *new* instances in Europe in the past *7 days*:", euInstances, regionHeaders, regionRow) + + evalHeaders := []string{"ID", "Name", "Org", "Owner", "Created", "Region", "Type/Quota", "Status"} + writeSection(w, "All *eval* instances (we want none):", evalInstances, evalHeaders, fullRow) + + expiredHeaders := []string{"ID", "Name", "Org", "Owner", "Expires", "Region", "Status"} + writeSection(w, "Instances that have *expiration date set*, they are usually removed 2 weeks after:", expiredInstances, expiredHeaders, expiredRow) + + failedHeaders := []string{"ID", "Name", "Org", "Owner", "Region", "Type/Quota", "Failed Reason"} + writeSection(w, "All *failed* instances:", failedCentrals, failedHeaders, failedRow) + + return nil +} + +type rowFunc func(c *adminAPI.Central) []string + +func writeSection(w io.Writer, title string, instances []adminAPI.Central, headers []string, rowFn rowFunc) { + fmt.Fprintf(w, "\n%s [%d]\n", title, len(instances)) + if len(instances) == 0 { + fmt.Fprintln(w, "(none)") + return + } + fmt.Fprintln(w, "```") + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + writeRow(tw, headers) + for i := range instances { + writeRow(tw, rowFn(&instances[i])) + } + tw.Flush() + fmt.Fprintln(w, "```") +} + +func writeRow(w io.Writer, values []string) { + for i, v := range values { + if i > 0 { + fmt.Fprint(w, "\t| ") + } + fmt.Fprint(w, v) + } + fmt.Fprintln(w) +} + +func orgColumn(c *adminAPI.Central) string { + name := c.OrganisationName + if name == "" { + name = "-" + } + if c.OrganisationId == "" { + return name + } + return fmt.Sprintf("%s (%s)", name, c.OrganisationId) +} + +func typeQuota(c *adminAPI.Central) string { + if c.QuotaType == "" { + return c.InstanceType + } + return c.InstanceType + "/" + c.QuotaType +} + +func regionRow(c *adminAPI.Central) []string { + return []string{c.Id, c.Name, orgColumn(c), c.Owner, c.CreatedAt.Format("2006-01-02"), + typeQuota(c), c.Status} +} + +func fullRow(c *adminAPI.Central) []string { + return []string{c.Id, c.Name, orgColumn(c), c.Owner, c.CreatedAt.Format("2006-01-02"), c.Region, + typeQuota(c), c.Status} +} + +func expiredRow(c *adminAPI.Central) []string { + expires := "-" + if c.ExpiredAt != nil { + expires = c.ExpiredAt.Format("2006-01-02 15:04") + } + return []string{c.Id, c.Name, orgColumn(c), c.Owner, expires, c.Region, c.Status} +} + +func failedRow(c *adminAPI.Central) []string { + reason := c.FailedReason + if reason == "" { + reason = "-" + } + return []string{c.Id, c.Name, orgColumn(c), c.Owner, c.Region, + typeQuota(c), reason} +} diff --git a/internal/central/pkg/cmd/admin/centrals/report_test.go b/internal/central/pkg/cmd/admin/centrals/report_test.go new file mode 100644 index 0000000000..59df038519 --- /dev/null +++ b/internal/central/pkg/cmd/admin/centrals/report_test.go @@ -0,0 +1,193 @@ +package centrals + +import ( + "bytes" + "testing" + "time" + + adminAPI "github.com/stackrox/acs-fleet-manager/internal/central/pkg/api/admin/private" + "github.com/stretchr/testify/assert" +) + +func newCentral(id, name, orgID, orgName, owner, region, instanceType, quotaType string) adminAPI.Central { + return adminAPI.Central{ + Id: id, + Name: name, + OrganisationId: orgID, + OrganisationName: orgName, + Owner: owner, + Region: region, + InstanceType: instanceType, + QuotaType: quotaType, + Status: "ready", + CreatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), + } +} + +func TestRegionRow(t *testing.T) { + c := newCentral("abc123", "my-central", "12345", "Acme Corp", "jdoe", "us-east-1", "standard", "ams") + + got := regionRow(&c) + + assert.Equal(t, []string{"abc123", "my-central", "Acme Corp (12345)", "jdoe", "2026-07-01", "standard/ams", "ready"}, got) +} + +func TestRegionRow_Provisioning(t *testing.T) { + c := newCentral("abc123", "my-central", "12345", "Acme Corp", "jdoe", "us-east-1", "standard", "ams") + c.Status = "provisioning" + + got := regionRow(&c) + + assert.Equal(t, []string{"abc123", "my-central", "Acme Corp (12345)", "jdoe", "2026-07-01", "standard/ams", "provisioning"}, got) +} + +func TestFullRow(t *testing.T) { + c := newCentral("abc123", "my-central", "12345", "Acme Corp", "jdoe", "us-east-1", "standard", "ams") + + got := fullRow(&c) + + assert.Equal(t, []string{"abc123", "my-central", "Acme Corp (12345)", "jdoe", "2026-07-01", "us-east-1", "standard/ams", "ready"}, got) +} + +func TestExpiredRow_WithExpiration(t *testing.T) { + c := newCentral("abc123", "my-central", "12345", "Acme Corp", "jdoe", "us-east-1", "standard", "ams") + expiry := time.Date(2026, 6, 15, 14, 30, 0, 0, time.UTC) + c.ExpiredAt = &expiry + + got := expiredRow(&c) + + assert.Equal(t, []string{"abc123", "my-central", "Acme Corp (12345)", "jdoe", "2026-06-15 14:30", "us-east-1", "ready"}, got) +} + +func TestExpiredRow_WithoutExpiration(t *testing.T) { + c := newCentral("abc123", "my-central", "12345", "Acme Corp", "jdoe", "us-east-1", "standard", "ams") + + got := expiredRow(&c) + + assert.Equal(t, []string{"abc123", "my-central", "Acme Corp (12345)", "jdoe", "-", "us-east-1", "ready"}, got) +} + +func TestOrgColumn_NoOrgId(t *testing.T) { + c := newCentral("abc123", "my-central", "", "Acme Corp", "jdoe", "us-east-1", "standard", "ams") + + got := orgColumn(&c) + + assert.Equal(t, "Acme Corp", got) +} + +func TestOrgColumn_NoOrgName(t *testing.T) { + c := newCentral("abc123", "my-central", "12345", "", "jdoe", "us-east-1", "standard", "ams") + + got := orgColumn(&c) + + assert.Equal(t, "- (12345)", got) +} + +func TestOrgColumn_NoBoth(t *testing.T) { + c := newCentral("abc123", "my-central", "", "", "jdoe", "us-east-1", "standard", "ams") + + got := orgColumn(&c) + + assert.Equal(t, "-", got) +} + +func TestTypeQuota_NoQuota(t *testing.T) { + c := newCentral("abc123", "my-central", "12345", "Acme Corp", "jdoe", "us-east-1", "standard", "") + + got := typeQuota(&c) + + assert.Equal(t, "standard", got) +} + +func TestWriteSection_Empty(t *testing.T) { + var b bytes.Buffer + writeSection(&b, "Test Section", nil, []string{"ID", "Name"}, fullRow) + + assert.Equal(t, "\nTest Section [0]\n(none)\n", b.String()) +} + +func TestWriteSection_WithInstances(t *testing.T) { + instances := []adminAPI.Central{ + newCentral("id1", "central-1", "100", "Acme", "alice", "us-east-1", "standard", "ams"), + newCentral("id2", "central-2", "200", "Globex", "bob", "eu-west-1", "eval", "quota-management-list"), + } + + var b bytes.Buffer + headers := []string{"ID", "Name", "Org", "Owner", "Created", "Region", "Type/Quota", "Status"} + writeSection(&b, "My Instances", instances, headers, fullRow) + + got := b.String() + assert.Contains(t, got, "My Instances [2]") + assert.Contains(t, got, "```") + assert.Contains(t, got, "ID") + assert.Contains(t, got, "| Name") + assert.Contains(t, got, "| Org") + assert.Contains(t, got, "id1") + assert.Contains(t, got, "| central-1") + assert.Contains(t, got, "| Acme (100)") + assert.Contains(t, got, "| alice") + assert.Contains(t, got, "id2") + assert.Contains(t, got, "| central-2") + assert.Contains(t, got, "| Globex (200)") + assert.Contains(t, got, "| bob") + assert.NotContains(t, got, "(none)") +} + +func TestWriteSection_WithExpiredFormatter(t *testing.T) { + c := newCentral("id1", "central-1", "100", "Acme", "alice", "us-east-1", "standard", "ams") + expiry := time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC) + c.ExpiredAt = &expiry + + var b bytes.Buffer + headers := []string{"ID", "Name", "Org", "Owner", "Expires", "Region", "Status"} + writeSection(&b, "Expired", []adminAPI.Central{c}, headers, expiredRow) + + got := b.String() + assert.Contains(t, got, "Expired [1]") + assert.Contains(t, got, "| Org") + assert.Contains(t, got, "| Expires") + assert.Contains(t, got, "| 2026-05-31 00:00") +} + +func TestWriteSection_ColumnsAligned(t *testing.T) { + instances := []adminAPI.Central{ + newCentral("short", "a", "1", "O", "x", "us-east-1", "s", "q"), + newCentral("much-longer-id", "longer-name", "99999", "OrgLong", "longowner", "eu-west-1", "standard", "ams"), + } + + var b bytes.Buffer + headers := []string{"ID", "Name", "Org ID"} + writeSection(&b, "Aligned", instances, headers, func(c *adminAPI.Central) []string { + return []string{c.Id, c.Name, c.OrganisationId} + }) + + got := b.String() + assert.Contains(t, got, "short | a | 1") + assert.Contains(t, got, "much-longer-id | longer-name | 99999") +} + +func TestFailedRow_WithReason(t *testing.T) { + c := newCentral("abc123", "my-central", "12345", "Acme Corp", "jdoe", "us-east-1", "standard", "ams") + c.FailedReason = "cluster capacity exceeded" + + got := failedRow(&c) + + assert.Equal(t, []string{"abc123", "my-central", "Acme Corp (12345)", "jdoe", "us-east-1", "standard/ams", "cluster capacity exceeded"}, got) +} + +func TestFailedRow_WithoutReason(t *testing.T) { + c := newCentral("abc123", "my-central", "12345", "Acme Corp", "jdoe", "us-east-1", "standard", "ams") + + got := failedRow(&c) + + assert.Equal(t, []string{"abc123", "my-central", "Acme Corp (12345)", "jdoe", "us-east-1", "standard/ams", "-"}, got) +} + +func TestBuildStatusFilter(t *testing.T) { + filter := buildStatusFilter() + + assert.Contains(t, filter, "status <> failed") + assert.NotContains(t, filter, "deprovision") + assert.NotContains(t, filter, "deleting") + assert.Equal(t, "status <> failed", filter) +} diff --git a/internal/central/pkg/cmd/admin/cmd.go b/internal/central/pkg/cmd/admin/cmd.go index f3ac2d5e4a..ab9aedb9f4 100644 --- a/internal/central/pkg/cmd/admin/cmd.go +++ b/internal/central/pkg/cmd/admin/cmd.go @@ -2,18 +2,55 @@ package admin import ( + "fmt" + "os" + "github.com/spf13/cobra" "github.com/stackrox/acs-fleet-manager/internal/central/pkg/cmd/admin/centrals" + "github.com/stackrox/acs-fleet-manager/internal/central/pkg/cmd/fleetmanagerclient" + impl "github.com/stackrox/acs-fleet-manager/pkg/client/fleetmanager/impl" ) // NewAdminCommand creates a new admin command. func NewAdminCommand() *cobra.Command { + var ( + apiURL string + authType string + ) + cmd := &cobra.Command{ - Use: "admin", - Short: "Perform admin API calls.", - Long: "Perform admin API calls. Use the STATIC_TOKEN to authenticate against its API.", - PersistentPreRun: func(cmd *cobra.Command, args []string) {}, + Use: "admin", + Short: "Perform admin API calls.", + Long: fmt.Sprintf(`Perform admin API calls against the fleet-manager admin API. + +Auth credentials are resolved from environment variables based on --auth-type: + %s: RHSSO_SERVICE_ACCOUNT_CLIENT_ID, RHSSO_SERVICE_ACCOUNT_CLIENT_SECRET, + RHSSO_ENDPOINT (default https://sso.redhat.com), RHSSO_REALM (default redhat-external) + %s: STATIC_TOKEN + %s: FLEET_MANAGER_TOKEN_FILE`, + impl.RHSSOAuthName, impl.StaticTokenAuthName, impl.ServiceAccountTokenAuthName), + PersistentPreRun: func(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + auth, err := impl.NewAuth(ctx, authType, impl.OptionFromEnv()) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "creating auth: %v\n", err) + os.Exit(1) + } + client, err := impl.NewClient(apiURL, auth) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "creating fleet-manager client: %v\n", err) + os.Exit(1) + } + cmd.SetContext(fleetmanagerclient.NewContext(ctx, client)) + }, } + + cmd.PersistentFlags().StringVar(&apiURL, "api-url", "https://api.openshift.com", + "Fleet Manager admin API base URL") + cmd.PersistentFlags().StringVar(&authType, "auth-type", impl.StaticTokenAuthName, + fmt.Sprintf("Auth type: %s, %s, or %s (credentials from env vars)", + impl.RHSSOAuthName, impl.StaticTokenAuthName, impl.ServiceAccountTokenAuthName)) + cmd.AddCommand( centrals.NewAdminCentralsCommand(), ) diff --git a/internal/central/pkg/cmd/fleetmanagerclient/client.go b/internal/central/pkg/cmd/fleetmanagerclient/client.go index 780346a712..137d2d7b3c 100644 --- a/internal/central/pkg/cmd/fleetmanagerclient/client.go +++ b/internal/central/pkg/cmd/fleetmanagerclient/client.go @@ -1,4 +1,4 @@ -// Package fleetmanagerclient is a fmAuthenticatedClientWithStaticToken for the CLI to connect to the fleetmanager. +// Package fleetmanagerclient provides helpers for CLI commands to obtain fleet-manager API clients. package fleetmanagerclient import ( @@ -8,65 +8,40 @@ import ( "sync" "time" - "github.com/golang/glog" "github.com/stackrox/acs-fleet-manager/pkg/client/fleetmanager" impl "github.com/stackrox/acs-fleet-manager/pkg/client/fleetmanager/impl" ) +type contextKey int + +const clientKey contextKey = iota + +// NewContext returns a context carrying the given fleet-manager client. +func NewContext(ctx context.Context, c *fleetmanager.Client) context.Context { + return context.WithValue(ctx, clientKey, c) +} + +// ClientFromContext returns the fleet-manager client stored in ctx. +// It panics if no client is found. +func ClientFromContext(ctx context.Context) *fleetmanager.Client { + c, ok := ctx.Value(clientKey).(*fleetmanager.Client) + if !ok || c == nil { + panic("fleet-manager client not found in context; this command must run under 'admin'") + } + return c +} + var ( singletonStaticTokenInstance sync.Once fmAuthenticatedClientWithStaticToken *fleetmanager.Client - - fmClientAuthWithRHOASToken *fleetmanager.Client - singletonRHOASTokenInstance sync.Once ) const ( defaultFleetManagerEndpoint = "http://localhost:8000" fleetManagerEndpointEnvVar = "FMCLI_FLEET_MANAGER_ENDPOINT" StaticTokenEnvVar = "STATIC_TOKEN" - rhoasTokenEnvVar = "RHOAS_TOKEN" ) -// AuthenticatedClientWithRHOASToken returns a rest client for fleet-manager API using a static OCM token for authentication. -// This function should only be used for CLI commands. -func AuthenticatedClientWithRHOASToken(ctx context.Context) *fleetmanager.Client { - rhoasToken := os.Getenv(rhoasTokenEnvVar) - if rhoasToken == "" { - panic(fmt.Sprintf("%s not set. Please set RHOAS token with 'export %s='", rhoasTokenEnvVar, rhoasTokenEnvVar)) - } - - fleetManagerEndpoint := os.Getenv(fleetManagerEndpointEnvVar) - if fleetManagerEndpoint == "" { - fleetManagerEndpoint = defaultFleetManagerEndpoint - } - - singletonRHOASTokenInstance.Do(func() { - auth, err := impl.NewAuth(ctx, impl.StaticTokenAuthName, impl.Option{ - Static: impl.StaticOption{ - StaticToken: rhoasToken, - }, - }) - if err != nil { - glog.Fatalf("Failed to create connection: %s", err) - return - } - - fmClientAuthWithRHOASToken, err = impl.NewClient(fleetManagerEndpoint, auth) - if err != nil { - glog.Fatalf("Failed to create connection: %s", err) - return - } - }) - - // sleep timer necessary to avoid "token issued in future" errors for time lags between fleet-manager running on a - // local VM and the OCM server. - if fleetManagerEndpoint == defaultFleetManagerEndpoint { - time.Sleep(5 * time.Second) - } - return fmClientAuthWithRHOASToken -} - // AuthenticatedClientWithStaticToken returns a rest client to the fleet-manager and receives the static token. // This function will panic on an error, designed to be used by the fleet-manager CLI. func AuthenticatedClientWithStaticToken(ctx context.Context) *fleetmanager.Client { @@ -87,14 +62,14 @@ func AuthenticatedClientWithStaticToken(ctx context.Context) *fleetmanager.Clien }, }) if err != nil { - glog.Fatalf("Failed to create connection: %s", err) - return + _, _ = fmt.Fprintf(os.Stderr, "Failed to create connection: %s\n", err) + os.Exit(1) } fmAuthenticatedClientWithStaticToken, err = impl.NewClient(fleetManagerEndpoint, auth) if err != nil { - glog.Fatalf("Failed to create connection: %s", err) - return + _, _ = fmt.Fprintf(os.Stderr, "Failed to create connection: %s\n", err) + os.Exit(1) } }) diff --git a/internal/central/pkg/presenters/admin_central.go b/internal/central/pkg/presenters/admin_central.go index 433a95b34e..90313390fb 100644 --- a/internal/central/pkg/presenters/admin_central.go +++ b/internal/central/pkg/presenters/admin_central.go @@ -13,22 +13,25 @@ import ( // PresentCentralRequestAdminEndpoint presents a dbapi.CentralRequest as an admin.Central. func PresentCentralRequestAdminEndpoint(request *dbapi.CentralRequest, _ account.AccountService) (*admin.Central, *errors.ServiceError) { return &admin.Central{ - Id: request.ID, - Kind: "CentralRequest", - Href: fmt.Sprintf("/api/rhacs/v1/centrals/%s", request.ID), - Status: request.Status, - CloudProvider: request.CloudProvider, - MultiAz: request.MultiAZ, - Region: request.Region, - Owner: request.Owner, - Name: request.Name, - Host: request.GetUIHost(), // TODO(ROX-11990): Split the Host in Fleet Manager Public API to UI and Data hosts - CreatedAt: request.CreatedAt, - UpdatedAt: request.UpdatedAt, - ExpiredAt: dbapi.NullTimeToTimePtr(request.ExpiredAt), - FailedReason: request.FailedReason, - InstanceType: request.InstanceType, - Traits: request.Traits, - ClusterId: request.ClusterID, + Id: request.ID, + Kind: "CentralRequest", + Href: fmt.Sprintf("/api/rhacs/v1/centrals/%s", request.ID), + Status: request.Status, + CloudProvider: request.CloudProvider, + MultiAz: request.MultiAZ, + Region: request.Region, + Owner: request.Owner, + Name: request.Name, + Host: request.GetUIHost(), // TODO(ROX-11990): Split the Host in Fleet Manager Public API to UI and Data hosts + CreatedAt: request.CreatedAt, + UpdatedAt: request.UpdatedAt, + ExpiredAt: dbapi.NullTimeToTimePtr(request.ExpiredAt), + FailedReason: request.FailedReason, + InstanceType: request.InstanceType, + QuotaType: request.QuotaType, + Traits: request.Traits, + OrganisationId: request.OrganisationID, + OrganisationName: request.OrganisationName, + ClusterId: request.ClusterID, }, nil } diff --git a/openapi/fleet-manager-private-admin.yaml b/openapi/fleet-manager-private-admin.yaml index 6cb51b5e19..aa3e11fe45 100644 --- a/openapi/fleet-manager-private-admin.yaml +++ b/openapi/fleet-manager-private-admin.yaml @@ -722,6 +722,8 @@ components: type: string organisation_id: type: string + organisation_name: + type: string subscription_id: type: string owner_account_id: From 537c333a0af7e39a00f09c6e07bd3b8ceae6df96 Mon Sep 17 00:00:00 2001 From: Yury Kovalev Date: Fri, 7 Aug 2026 17:13:53 +0200 Subject: [PATCH 2/3] ROX-35081: Fix infinite loop when API returns empty page in pagination The pagination loop in fetchAllCentrals only terminated when the accumulated count reached list.Total, which could spin forever if the API returned an empty Items slice. Break early on empty pages. Co-Authored-By: Claude Opus 4.6 --- internal/central/pkg/cmd/admin/centrals/report.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/central/pkg/cmd/admin/centrals/report.go b/internal/central/pkg/cmd/admin/centrals/report.go index 2b681dc71c..7d4b11a319 100644 --- a/internal/central/pkg/cmd/admin/centrals/report.go +++ b/internal/central/pkg/cmd/admin/centrals/report.go @@ -64,6 +64,10 @@ func fetchAllCentrals(ctx context.Context, api reportAPI, search string) ([]admi return nil, fmt.Errorf("fetching centrals (page %d): %w", page, err) } + if len(list.Items) == 0 { + break + } + all = append(all, list.Items...) if int32(len(all)) >= list.Total { From 0075ddc53418849eb267477f349b0257a2131c0e Mon Sep 17 00:00:00 2001 From: Yury Kovalev Date: Mon, 10 Aug 2026 14:14:24 +0200 Subject: [PATCH 3/3] Delete probe client --- probe/pkg/fleetmanager/client.go | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 probe/pkg/fleetmanager/client.go diff --git a/probe/pkg/fleetmanager/client.go b/probe/pkg/fleetmanager/client.go deleted file mode 100644 index 6ad52a7e33..0000000000 --- a/probe/pkg/fleetmanager/client.go +++ /dev/null @@ -1,26 +0,0 @@ -// Package fleetmanager ... -package fleetmanager - -import ( - "context" - - "github.com/pkg/errors" - "github.com/stackrox/acs-fleet-manager/pkg/client/fleetmanager" - "github.com/stackrox/acs-fleet-manager/pkg/client/fleetmanager/impl" - "github.com/stackrox/acs-fleet-manager/probe/config" -) - -// New creates a new fleet manager client. -func New(ctx context.Context, config config.Config) (fleetmanager.PublicAPI, error) { - auth, err := impl.NewAuth(ctx, config.AuthType, impl.OptionFromEnv()) - if err != nil { - return nil, errors.Wrap(err, "failed to create fleet manager authentication") - } - - client, err := impl.NewClient(config.FleetManagerEndpoint, auth, impl.WithUserAgent("fleet-manager-probe-service")) - if err != nil { - return nil, errors.Wrap(err, "failed to create fleet manager client") - } - - return client.PublicAPI(), nil -}