diff --git a/.tekton/acs-instance-report-cron.yaml b/.tekton/acs-instance-report-cron.yaml new file mode 100644 index 0000000000..2ba7302fc6 --- /dev/null +++ b/.tekton/acs-instance-report-cron.yaml @@ -0,0 +1,168 @@ +apiVersion: tekton.dev/v1 +kind: PipelineRun +metadata: + name: acs-instance-report-cron + namespace: acscs-rhacs-tenant + annotations: + pipelinesascode.tekton.dev/on-cron-expression: "0 7 * * 5" + pipelinesascode.tekton.dev/target-branch: main + pipelinesascode.tekton.dev/max-keep-runs: "3" + labels: + appstudio.openshift.io/application: acscs-main +spec: + params: + - name: git-url + value: '{{source_url}}' + - name: revision + value: '{{revision}}' + - name: konflux-base-url + value: "https://konflux-ui.apps.stone-prd-rh01.pg1f.p1.openshiftapps.com" + - name: konflux-application-name + value: acscs-main + pipelineSpec: + params: + - name: git-url + type: string + - name: revision + type: string + - name: konflux-base-url + type: string + - name: konflux-application-name + type: string + tasks: + - name: clone-repository + params: + - name: url + value: $(params.git-url) + - name: revision + value: $(params.revision) + taskRef: + resolver: bundles + params: + - name: name + value: git-clone + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-git-clone:0.1@sha256:fedaacbf05ff7b2cdd36bff6cb1f103755cb5dc0b4adc0540136d3606ade18a5 + - name: kind + value: task + workspaces: + - name: output + workspace: workspace + - name: basic-auth + workspace: git-auth + - name: build-and-report + runAfter: + - clone-repository + params: + - name: pipeline-run-url + value: "$(params.konflux-base-url)/ns/$(context.pipelineRun.namespace)/applications/$(params.konflux-application-name)/pipelineruns/$(context.pipelineRun.name)" + - name: pipeline-run-name + value: $(context.pipelineRun.name) + workspaces: + - name: source + workspace: workspace + taskSpec: + params: + - name: pipeline-run-url + type: string + - name: pipeline-run-name + type: string + results: + - name: message + description: Formatted report message for Slack + workspaces: + - name: source + steps: + - name: build-and-report + image: registry.access.redhat.com/ubi9/go-toolset:1.26.3@sha256:430cd439ea3bb4af3727a843c0302594bb1973211ecbfc98e4450a40798075df + workingDir: $(workspaces.source.path) + env: + - name: RHSSO_SERVICE_ACCOUNT_CLIENT_ID + valueFrom: + secretKeyRef: + name: acs-instance-report + key: client-id + - name: RHSSO_SERVICE_ACCOUNT_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: acs-instance-report + key: client-secret + - name: RHSSO_ENDPOINT + value: "https://auth.redhat.com" + - name: RHSSO_REALM + value: "EmployeeIDP" + - name: FLEET_MANAGER_API_URL + value: "https://api.openshift.com" + - name: PIPELINE_RUN_NAME + value: $(params.pipeline-run-name) + - name: PIPELINE_RUN_URL + value: $(params.pipeline-run-url) + script: | + #!/bin/bash + set -u + + if ! BUILD_OUTPUT=$(make fleet-manager 2>&1); then + MESSAGE="Failed to build fleet-manager binary\n\n${BUILD_OUTPUT}" + EXIT_CODE=1 + else + REPORT=$(./fleet-manager admin central report \ + --api-url="${FLEET_MANAGER_API_URL}" \ + --auth-type=RHSSO 2>&1) + EXIT_CODE=$? + + if [ "${EXIT_CODE}" -ne 0 ]; then + MESSAGE="Instance report command returned error (exit code ${EXIT_CODE})\n\nOutput:\n${REPORT}" + else + MESSAGE="${REPORT}" + fi + fi + + PIPELINE_LINK="<${PIPELINE_RUN_URL}|${PIPELINE_RUN_NAME}>" + printf '%s' "${MESSAGE}\n\nPipeline run: ${PIPELINE_LINK}" > $(results.message.path) + exit "${EXIT_CODE}" + computeResources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + finally: + - name: send-notification + params: + - name: message + value: $(tasks.build-and-report.results.message) + - name: secret-name + value: acs-instance-report + - name: key-name + value: slack-webhook-url + taskRef: + resolver: bundles + params: + - name: name + value: slack-webhook-notification + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-slack-webhook-notification:0.1@sha256:d34a3789505f829493636a265eb04790695dba84bbb2bb716a7551a6911f2816 + - name: kind + value: task + workspaces: + - name: workspace + - name: git-auth + optional: true + taskRunTemplate: + serviceAccountName: build-pipeline-acs-fleet-manager + workspaces: + - name: workspace + volumeClaimTemplate: + metadata: + creationTimestamp: null + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + status: {} + - name: git-auth + secret: + secretName: '{{ git_auth_secret }}' 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..48de14ef4f 100644 --- a/cmd/fleet-manager/main.go +++ b/cmd/fleet-manager/main.go @@ -4,6 +4,7 @@ package main import ( "flag" + "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" @@ -44,6 +45,7 @@ func main() { 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. 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..a868360138 100644 --- a/internal/central/pkg/cmd/admin/cmd.go +++ b/internal/central/pkg/cmd/admin/cmd.go @@ -2,18 +2,53 @@ package admin import ( + "fmt" + + "github.com/golang/glog" "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 { + glog.Fatalf("creating auth: %v", err) + } + client, err := impl.NewClient(apiURL, auth) + if err != nil { + glog.Fatalf("creating fleet-manager client: %v", err) + } + 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..20dbe65e67 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 ( @@ -13,60 +13,36 @@ import ( 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 { 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: