From 90db7851b2752d479ccd01f5419a824968911cd4 Mon Sep 17 00:00:00 2001 From: Emily Zhang Date: Thu, 13 Aug 2026 15:40:24 -0700 Subject: [PATCH 1/4] feat(xid): add CLI/SDK support for xid bursts Signed-off-by: Emily Zhang --- cmd/nvfleetint/root.go | 1 + cmd/nvfleetint/xid.go | 478 +++++++++++++++++++++++++++++++++ cmd/nvfleetint/xid_test.go | 305 +++++++++++++++++++++ docs/cli.md | 12 + internal/output/output.go | 9 + internal/output/output_test.go | 5 + nvfleetint/common.go | 30 ++- nvfleetint/xid.go | 334 +++++++++++++++++++++++ nvfleetint/xid_test.go | 304 +++++++++++++++++++++ 9 files changed, 1477 insertions(+), 1 deletion(-) create mode 100644 cmd/nvfleetint/xid.go create mode 100644 cmd/nvfleetint/xid_test.go create mode 100644 nvfleetint/xid.go create mode 100644 nvfleetint/xid_test.go diff --git a/cmd/nvfleetint/root.go b/cmd/nvfleetint/root.go index 3c5bf31..4100f29 100644 --- a/cmd/nvfleetint/root.go +++ b/cmd/nvfleetint/root.go @@ -70,6 +70,7 @@ func newRootCmd() *cobra.Command { cmd.AddCommand(newNodeCmd()) cmd.AddCommand(newAlertCmd()) cmd.AddCommand(newEventCmd()) + cmd.AddCommand(newXIDCmd()) cmd.AddCommand(newReportCmd()) cmd.AddCommand(newTagCmd()) cmd.AddCommand(newVersionCmd()) diff --git a/cmd/nvfleetint/xid.go b/cmd/nvfleetint/xid.go new file mode 100644 index 0000000..87ec911 --- /dev/null +++ b/cmd/nvfleetint/xid.go @@ -0,0 +1,478 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "io" + "sort" + "strconv" + "strings" + + "github.com/NVIDIA/fleet-intelligence-client/internal/clihelpers" + clioutput "github.com/NVIDIA/fleet-intelligence-client/internal/output" + "github.com/NVIDIA/fleet-intelligence-client/nvfleetint" + + "github.com/spf13/cobra" +) + +// Lists the sort fields accepted by xid burst list +const xidBurstSortByList = "startTime, hostname, nodeGroup, computeZone, xidNumbers, jobDisruption, " + + "jobDisruptionDueToPlatformIssue, category, subcategory, tenantAction, tenantInvestigation, " + + "dcAdminAction, or dcAdminInvestigation" + +// Stores local flag values for xid burst list +type xidBurstListFlags struct { + window string + start string + end string + node string + nodeGroupIDs string + computeZoneIDs string + jobDisruption bool + platformDisruption bool + xidNumbers string + hostname string + categorySearch string + subcategorySearch string + tenantActionSearch string + tenantInvestigationSearch string + dcAdminActionSearch string + dcAdminInvestigationSearch string + categories string + subcategories string + tenantActions string + tenantInvestigations string + dcAdminActions string + dcAdminInvestigations string + sortBy string + order string +} + +// Stores data ready for xid burst list rendering +type xidBurstListOutput struct { + Bursts []nvfleetint.XIDBurst + JSONValue any + RawJSON []byte + Page *clioutput.Pagination +} + +// Creates the top-level xid command group +func newXIDCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "xid", + Short: "Inspect XID diagnostics", + } + + cmd.AddCommand(newXIDBurstCmd()) + rejectUnknownSubcommands(cmd) + + return cmd +} + +// Creates the xid burst command group +func newXIDBurstCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "burst", + Short: "Inspect finalized XID bursts", + Long: "Inspect finalized XID bursts: groups of XID errors observed together on one node.\n\n" + + "Workflow: list → describe. List the bursts in a time range, then describe one for its " + + "impacted devices, XID catalog details, and suggested actions.", + Example: " nvfleetint xid burst list --window 24h\n" + + " nvfleetint xid burst list --window 168h --job-disruption --sort-by startTime\n" + + " nvfleetint xid burst describe ", + } + + cmd.AddCommand(newXIDBurstListCmd()) + cmd.AddCommand(newXIDBurstDescribeCmd()) + rejectUnknownSubcommands(cmd) + + return cmd +} + +// Creates the xid burst list command +func newXIDBurstListCmd() *cobra.Command { + flags := xidBurstListFlags{} + common := newCommonFlags() + + cmd := &cobra.Command{ + Use: "list", + Short: "List finalized XID bursts", + Args: cobra.NoArgs, + Long: `List finalized XID bursts filtered by node scope, XID numbers, classification, +and a time range. + +A time range is required: use --window for a relative range, or --start and +--end for an absolute range. The range applies to each burst's start time. + +Some filters and response fields are only available to cloud-provider/NCP +callers; for tenant callers the backend rejects them.`, + Example: ` nvfleetint xid burst list --window 24h + nvfleetint xid burst list --start 2026-05-01T00:00:00Z --end 2026-05-08T00:00:00Z + nvfleetint xid burst list --window 168h --xid-numbers 48,94 --job-disruption`, + RunE: func(cmd *cobra.Command, _ []string) error { + return runXIDBurstList(cmd, flags, resolveCommonFlags(cmd, common)) + }, + } + + registerEventTimeFlags(cmd, &flags.window, &flags.start, &flags.end) + cmd.Flags().StringVar(&flags.node, "node", "", "Filter by node UUID") + cmd.Flags().StringVar(&flags.nodeGroupIDs, "nodegroup-ids", "", "Comma-separated node group IDs to filter") + cmd.Flags().StringVar(&flags.computeZoneIDs, "compute-zone-ids", "", "Comma-separated compute zone IDs to filter") + cmd.Flags().BoolVar(&flags.jobDisruption, "job-disruption", false, "Filter by the public fatal-XID job-disruption value") + cmd.Flags().BoolVar(&flags.platformDisruption, "platform-disruption", false, "Filter by analyzer platform-attributed disruption (cloud-provider/NCP only)") + cmd.Flags().StringVar(&flags.xidNumbers, "xid-numbers", "", "Comma-separated XID numbers; a burst matches if it contains any of them") + cmd.Flags().StringVar(&flags.hostname, "hostname", "", "Hostname partial match") + cmd.Flags().StringVar(&flags.categorySearch, "category-search", "", "Category partial match (cloud-provider/NCP only)") + cmd.Flags().StringVar(&flags.subcategorySearch, "subcategory-search", "", "Subcategory partial match (cloud-provider/NCP only)") + cmd.Flags().StringVar(&flags.tenantActionSearch, "tenant-action-search", "", "Tenant immediate-action code partial match") + cmd.Flags().StringVar(&flags.tenantInvestigationSearch, "tenant-investigation-search", "", "Tenant investigatory-action code partial match") + cmd.Flags().StringVar(&flags.dcAdminActionSearch, "dc-admin-action-search", "", "DC-admin immediate-action code partial match (cloud-provider/NCP only)") + cmd.Flags().StringVar(&flags.dcAdminInvestigationSearch, "dc-admin-investigation-search", "", "DC-admin investigatory-action code partial match (cloud-provider/NCP only)") + cmd.Flags().StringVar(&flags.categories, "categories", "", "Comma-separated exact category values to filter (cloud-provider/NCP only)") + cmd.Flags().StringVar(&flags.subcategories, "subcategories", "", "Comma-separated exact subcategory values to filter (cloud-provider/NCP only)") + cmd.Flags().StringVar(&flags.tenantActions, "tenant-actions", "", "Comma-separated exact tenant immediate-action codes to filter") + cmd.Flags().StringVar(&flags.tenantInvestigations, "tenant-investigations", "", "Comma-separated exact tenant investigatory-action codes to filter") + cmd.Flags().StringVar(&flags.dcAdminActions, "dc-admin-actions", "", "Comma-separated exact DC-admin immediate-action codes to filter (cloud-provider/NCP only)") + cmd.Flags().StringVar(&flags.dcAdminInvestigations, "dc-admin-investigations", "", "Comma-separated exact DC-admin investigatory-action codes to filter (cloud-provider/NCP only)") + cmd.Flags().StringVar(&flags.sortBy, "sort-by", "", "Sort field: "+xidBurstSortByList) + cmd.Flags().StringVar(&flags.order, "order", "", "Sort order: asc or desc") + registerListCommonFlags(cmd, common) + + return cmd +} + +// Creates the xid burst describe command +func newXIDBurstDescribeCmd() *cobra.Command { + common := newCommonFlags() + cmd := &cobra.Command{ + Use: "describe ", + Short: "Describe a finalized XID burst", + Args: requireSingleArg("burst ID"), + RunE: func(cmd *cobra.Command, args []string) error { + return runXIDBurstDescribe(cmd, args[0], resolveCommonFlags(cmd, common)) + }, + } + + registerReadCommonFlags(cmd, common) + + return cmd +} + +// Validates flags, calls the SDK, and writes output +func runXIDBurstList(cmd *cobra.Command, flags xidBurstListFlags, common resolvedCommonFlags) error { + if err := validateListCommonFlags(common); err != nil { + return err + } + if err := validateEventTimeFlags(flags.window, flags.start, flags.end); err != nil { + return err + } + if flags.sortBy != "" && !nvfleetint.XIDBurstSortBy(strings.TrimSpace(flags.sortBy)).Valid() { + return fmt.Errorf("invalid sort-by %q: expected %s", flags.sortBy, xidBurstSortByList) + } + if flags.order != "" && !nvfleetint.XIDBurstSortOrder(strings.TrimSpace(flags.order)).Valid() { + return fmt.Errorf("invalid order %q: expected asc or desc", flags.order) + } + + opts := nvfleetint.ListXIDBurstsOptions{ + Window: strings.TrimSpace(flags.window), + StartTime: strings.TrimSpace(flags.start), + EndTime: strings.TrimSpace(flags.end), + NodeUUID: strings.TrimSpace(flags.node), + HostnameSearch: strings.TrimSpace(flags.hostname), + CategorySearch: strings.TrimSpace(flags.categorySearch), + SubcategorySearch: strings.TrimSpace(flags.subcategorySearch), + TenantActionSearch: strings.TrimSpace(flags.tenantActionSearch), + TenantInvestigationSearch: strings.TrimSpace(flags.tenantInvestigationSearch), + DCAdminActionSearch: strings.TrimSpace(flags.dcAdminActionSearch), + DCAdminInvestigationSearch: strings.TrimSpace(flags.dcAdminInvestigationSearch), + SortBy: nvfleetint.XIDBurstSortBy(strings.TrimSpace(flags.sortBy)), + SortOrder: nvfleetint.XIDBurstSortOrder(strings.TrimSpace(flags.order)), + } + + // Boolean filters are tri-state: omitted means "either", so they are only + // forwarded when the user set them. + if cmd.Flags().Changed("job-disruption") { + opts.JobDisruption = &flags.jobDisruption + } + if cmd.Flags().Changed("platform-disruption") { + opts.JobDisruptionDueToPlatformIssue = &flags.platformDisruption + } + + xidNumbers, err := parseXIDNumberList(flags.xidNumbers) + if err != nil { + return err + } + opts.XIDNumbers = xidNumbers + + for _, filter := range []struct { + name string + raw string + assign func([]string) + }{ + {name: "nodegroup-ids", raw: flags.nodeGroupIDs, assign: func(v []string) { opts.NodeGroupIDs = v }}, + {name: "compute-zone-ids", raw: flags.computeZoneIDs, assign: func(v []string) { opts.ComputeZoneIDs = v }}, + {name: "categories", raw: flags.categories, assign: func(v []string) { opts.Categories = v }}, + {name: "subcategories", raw: flags.subcategories, assign: func(v []string) { opts.Subcategories = v }}, + {name: "tenant-actions", raw: flags.tenantActions, assign: func(v []string) { opts.TenantActions = v }}, + {name: "tenant-investigations", raw: flags.tenantInvestigations, assign: func(v []string) { opts.TenantInvestigations = v }}, + {name: "dc-admin-actions", raw: flags.dcAdminActions, assign: func(v []string) { opts.DCAdminActions = v }}, + {name: "dc-admin-investigations", raw: flags.dcAdminInvestigations, assign: func(v []string) { opts.DCAdminInvestigations = v }}, + } { + values, err := clihelpers.ParseCommaList(filter.raw) + if err != nil { + return fmt.Errorf("invalid --%s: %w", filter.name, err) + } + filter.assign(values) + } + + client, err := newConfiguredClient(common) + if err != nil { + return err + } + applyPagination(common, func(page *int) { opts.Page = page }, func(pageSize *int) { opts.PageSize = pageSize }) + + if common.all { + var bursts []nvfleetint.XIDBurst + result, err := clihelpers.FetchAllRawPages("items", 0, func(pageNumber int) (clihelpers.RawPage, error) { + page := pageNumber + opts.Page = &page + currentPage, err := client.ListXIDBursts(cmd.Context(), opts) + if err != nil { + return clihelpers.RawPage{}, err + } + bursts = append(bursts, currentPage.Bursts...) + hasMore := xidBurstPageHasMore(currentPage) + return clihelpers.RawPage{ + RawJSON: currentPage.RawJSON, + Page: currentPage.Page, + PageSize: currentPage.PageSize, + Total: currentPage.Total, + HasMore: &hasMore, + }, nil + }) + if err != nil { + return err + } + return writeXIDBurstListOutput(cmd.OutOrStdout(), common, xidBurstListOutput{ + Bursts: bursts, + JSONValue: result, + }) + } + + page, err := client.ListXIDBursts(cmd.Context(), opts) + if err != nil { + return err + } + return writeXIDBurstListOutput(cmd.OutOrStdout(), common, xidBurstListOutput{ + Bursts: page.Bursts, + RawJSON: page.RawJSON, + Page: &clioutput.Pagination{ + Page: page.Page, + PageSize: page.PageSize, + Total: page.Total, + }, + }) +} + +// Validates args, calls the SDK, and writes output +func runXIDBurstDescribe(cmd *cobra.Command, burstID string, common resolvedCommonFlags) error { + if err := validateReadCommonFlags(common); err != nil { + return err + } + + client, err := newConfiguredClient(common) + if err != nil { + return err + } + + burst, err := client.DescribeXIDBurst(cmd.Context(), strings.TrimSpace(burstID)) + if err != nil { + return err + } + if common.output == clioutput.FormatJSON { + return clioutput.WriteRawJSON(cmd.OutOrStdout(), burst.RawJSON) + } + return writeXIDBurstDescribeTable(cmd.OutOrStdout(), burst) +} + +// Converts comma-separated XID numbers into API values +func parseXIDNumberList(raw string) ([]int, error) { + values, err := clihelpers.ParseCommaList(raw) + if err != nil { + return nil, fmt.Errorf("invalid --xid-numbers: %w", err) + } + if len(values) == 0 { + return nil, nil + } + + numbers := make([]int, 0, len(values)) + for _, value := range values { + number, err := strconv.Atoi(value) + if err != nil { + return nil, fmt.Errorf("invalid xid-numbers %q: expected an integer", value) + } + if number < 0 { + return nil, fmt.Errorf("invalid xid-numbers %q: expected a non-negative integer", value) + } + numbers = append(numbers, number) + } + + return numbers, nil +} + +// Reports whether an XID burst list response has another page. The endpoint +// reports no hasMore flag, so it is derived from the page counters. +func xidBurstPageHasMore(page nvfleetint.XIDBurstsPage) bool { + if page.Page < 0 || page.PageSize <= 0 || page.Total <= 0 { + return false + } + // Page is 0-indexed, so the first (page+1) pages have been seen so far. + return (page.Page+1)*page.PageSize < page.Total +} + +// Writes JSON or table output for xid burst list results +func writeXIDBurstListOutput(w io.Writer, common resolvedCommonFlags, result xidBurstListOutput) error { + if common.output == clioutput.FormatJSON { + return writePaginatedListJSON(w, result.RawJSON, result.JSONValue) + } + + headers := []string{ + "BURST ID", "HOSTNAME", "XIDS", "XID COUNT", "START TIME", "DURATION (S)", + "JOB DISRUPTION", "NODE GROUP", "COMPUTE ZONE", + } + if err := clioutput.WriteTable(w, headers, xidBurstRows(result.Bursts)); err != nil { + return err + } + if result.Page == nil { + return nil + } + return clioutput.WritePaginationFooter(w, *result.Page) +} + +// Converts XID bursts into table rows. Category, subcategory, device IDs, and +// suggested actions are omitted to keep the table narrow; use describe or +// -o json for the full payload. +func xidBurstRows(bursts []nvfleetint.XIDBurst) [][]string { + rows := make([][]string, 0, len(bursts)) + for _, burst := range bursts { + rows = append(rows, []string{ + clioutput.DisplayString(burst.BurstID), + clioutput.DisplayString(burst.Hostname), + formatXIDNumbers(burst.XIDNumbers), + clioutput.FormatOptionalInt(burst.XIDCount), + clioutput.DisplayString(burst.StartTime), + clioutput.FormatOptionalInt(burst.BurstDurationSeconds), + clioutput.FormatOptionalBool(burst.JobDisruption), + clioutput.DisplayString(burst.NodeGroup), + clioutput.DisplayString(burst.ComputeZone), + }) + } + return rows +} + +// Renders XID burst detail fields as a table +func writeXIDBurstDescribeTable(w io.Writer, burst nvfleetint.XIDBurstDetails) error { + return clioutput.WriteTable(w, []string{"FIELD", "VALUE"}, xidBurstDescribeRows(burst)) +} + +// Converts XID burst details into describe table rows +func xidBurstDescribeRows(burst nvfleetint.XIDBurstDetails) [][]string { + rows := [][]string{ + {"BURST ID", clioutput.DisplayString(burst.BurstID)}, + {"NODE UUID", clioutput.DisplayString(burst.NodeUUID)}, + {"HOSTNAME", clioutput.DisplayString(burst.Hostname)}, + {"NODE GROUP", clioutput.FormatNameAndID(burst.NodeGroup, burst.NodeGroupID)}, + {"COMPUTE ZONE", clioutput.FormatNameAndID(burst.ComputeZone, burst.ComputeZoneID)}, + {"START TIME", clioutput.DisplayString(burst.StartTime)}, + {"END TIME", clioutput.DisplayString(burst.EndTime)}, + {"DURATION (S)", clioutput.FormatOptionalInt(burst.BurstDurationSeconds)}, + {"XID COUNT", clioutput.FormatOptionalInt(burst.XIDCount)}, + {"XIDS", formatXIDNumbers(burst.XIDNumbers)}, + {"JOB DISRUPTION", clioutput.FormatOptionalBool(burst.JobDisruption)}, + {"PLATFORM JOB DISRUPTION", clioutput.FormatOptionalBool(burst.JobDisruptionDueToPlatformIssue)}, + {"CATEGORY", clioutput.DisplayString(burst.Category)}, + {"SUBCATEGORY", clioutput.DisplayString(burst.Subcategory)}, + {"STICKY XIDS SUPPRESSED", clioutput.FormatOptionalInt(burst.StickyXIDsSuppressed)}, + } + + for _, xid := range burst.XIDNumbers { + detail := strings.TrimSpace(strings.Join(nonEmptyValues(xid.Mnemonic, xid.Description), ": ")) + if detail == "" { + continue + } + rows = append(rows, []string{"XID " + clioutput.FormatOptionalInt(xid.XIDNumber), detail}) + } + rows = append(rows, xidBurstDeviceRows(burst.DeviceIDs)...) + rows = append(rows, xidBurstActionRows(burst.SuggestedActions)...) + + return rows +} + +// Converts the impacted-device map into describe table rows, sorted by device +// ID so repeated runs render identically +func xidBurstDeviceRows(devices map[string][]int) [][]string { + if len(devices) == 0 { + return nil + } + deviceIDs := make([]string, 0, len(devices)) + for deviceID := range devices { + deviceIDs = append(deviceIDs, deviceID) + } + sort.Strings(deviceIDs) + + rows := make([][]string, 0, len(deviceIDs)) + for _, deviceID := range deviceIDs { + rows = append(rows, []string{"DEVICE " + deviceID, formatIntList(devices[deviceID])}) + } + return rows +} + +// Converts suggested actions into describe table rows +func xidBurstActionRows(actions []nvfleetint.SuggestedAction) [][]string { + rows := make([][]string, 0, len(actions)) + for _, action := range actions { + label := strings.TrimSpace(strings.Join(nonEmptyValues(action.Persona, action.Type), " ")) + if label == "" { + label = "SUGGESTED ACTION" + } + rows = append(rows, []string{ + strings.ToUpper(label), + clioutput.DisplayString(strings.Join(nonEmptyValues(action.Code, action.Action), ": ")), + }) + } + return rows +} + +// Formats burst XID numbers as a comma-separated list +func formatXIDNumbers(xids []nvfleetint.XIDBurstXID) string { + values := make([]string, 0, len(xids)) + for _, xid := range xids { + if xid.XIDNumber == nil { + continue + } + values = append(values, strconv.Itoa(*xid.XIDNumber)) + } + return clioutput.FormatStringList(values) +} + +// Formats integers as a comma-separated list +func formatIntList(values []int) string { + formatted := make([]string, 0, len(values)) + for _, value := range values { + formatted = append(formatted, strconv.Itoa(value)) + } + return clioutput.FormatStringList(formatted) +} + +// Returns the non-blank values in order +func nonEmptyValues(values ...string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + if strings.TrimSpace(value) != "" { + out = append(out, value) + } + } + return out +} diff --git a/cmd/nvfleetint/xid_test.go b/cmd/nvfleetint/xid_test.go new file mode 100644 index 0000000..6922949 --- /dev/null +++ b/cmd/nvfleetint/xid_test.go @@ -0,0 +1,305 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/NVIDIA/fleet-intelligence-client/nvfleetint" +) + +const xidBurstsBody = `{"items":[{"burstId":"burst-1","nodeUuid":"node-1","hostname":"gpu-01","nodeGroup":"ng","nodeGroupId":"ng-1","computeZone":"cz","computeZoneId":"cz-1","startTime":"2026-05-01T00:00:00Z","endTime":"2026-05-01T00:05:00Z","burstDurationSeconds":300,"xidCount":2,"xidNumbers":[{"xidNumber":48,"mnemonic":"DBE","description":"Double Bit ECC"},{"xidNumber":94,"mnemonic":"CE"}],"deviceIds":{"0000:0f:00.0":[48,94]},"jobDisruption":true,"category":"GPU","subcategory":"Memory","stickyXidsSuppressed":1,"suggestedActions":[{"action":"Drain the node","code":"DRAIN","persona":"tenant","type":"immediate"}]}],"page":0,"pageSize":20,"total":1}` + +// Verifies xid burst list table output and filter translation +func TestXIDBurstListTable(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/xid/bursts" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + query := r.URL.Query() + if got := query.Get("timeMode"); got != "relative" { + t.Fatalf("unexpected timeMode: %q", got) + } + if got := query.Get("window"); got != "24h" { + t.Fatalf("unexpected window: %q", got) + } + if got := query["xidNumbers"]; len(got) != 2 || got[0] != "48" || got[1] != "94" { + t.Fatalf("unexpected xidNumbers: %#v", got) + } + if got := query.Get("jobDisruption"); got != "true" { + t.Fatalf("unexpected jobDisruption: %q", got) + } + if got := query.Get("sortBy"); got != "startTime" { + t.Fatalf("unexpected sortBy: %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(xidBurstsBody)) + })) + defer server.Close() + + saveTestConfig(t, server.URL, "test-key") + + var out bytes.Buffer + cmd := newRootCmd() + cmd.SetOut(&out) + cmd.SetArgs([]string{ + "xid", "burst", "list", "--window", "24h", "--xid-numbers", "48,94", + "--job-disruption", "--sort-by", "startTime", + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("command failed: %v", err) + } + + got := out.String() + for _, want := range []string{ + "BURST ID", "HOSTNAME", "XIDS", "XID COUNT", "START TIME", "DURATION (S)", + "JOB DISRUPTION", "NODE GROUP", "COMPUTE ZONE", + "burst-1", "gpu-01", "48, 94", "2026-05-01T00:00:00Z", "300", "true", + "Total Entries: 1", + } { + if !strings.Contains(got, want) { + t.Fatalf("output missing %q:\n%s", want, got) + } + } + // Detail-only fields stay out of the list table. + for _, absent := range []string{"Double Bit ECC", "0000:0f:00.0", "Drain the node"} { + if strings.Contains(got, absent) { + t.Fatalf("output should not contain %q:\n%s", absent, got) + } + } +} + +// Verifies xid burst list JSON output is the raw payload with a 1-based page +func TestXIDBurstListJSON(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(xidBurstsBody)) + })) + defer server.Close() + + saveTestConfig(t, server.URL, "test-key") + + var out bytes.Buffer + cmd := newRootCmd() + cmd.SetOut(&out) + cmd.SetArgs([]string{"xid", "burst", "list", "--window", "24h", "--output", "json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("command failed: %v", err) + } + + got := strings.TrimSpace(out.String()) + if !strings.Contains(got, `"page":1`) { + t.Fatalf("expected 1-based page in JSON output:\n%s", got) + } + // The raw backend payload is preserved, including the fields the table drops. + for _, want := range []string{`"burstId":"burst-1"`, `"deviceIds"`, `"Double Bit ECC"`} { + if !strings.Contains(got, want) { + t.Fatalf("output missing %q:\n%s", want, got) + } + } +} + +// Verifies xid burst list rejects invalid flags before any request +func TestXIDBurstListFlagValidation(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatal("did not expect a request for invalid flags") + })) + defer server.Close() + + saveTestConfig(t, server.URL, "test-key") + + tests := []struct { + name string + args []string + }{ + {"missing time range", []string{"xid", "burst", "list"}}, + {"window with start", []string{"xid", "burst", "list", "--window", "24h", "--start", "2026-05-01T00:00:00Z"}}, + {"bad sort field", []string{"xid", "burst", "list", "--window", "24h", "--sort-by", "burstId"}}, + {"bad order", []string{"xid", "burst", "list", "--window", "24h", "--order", "sideways"}}, + {"non-numeric xid", []string{"xid", "burst", "list", "--window", "24h", "--xid-numbers", "forty-eight"}}, + {"bad output", []string{"xid", "burst", "list", "--window", "24h", "--output", "yaml"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := newRootCmd() + cmd.SetOut(new(bytes.Buffer)) + cmd.SetArgs(tt.args) + if err := cmd.Execute(); err == nil { + t.Fatalf("expected error for %v", tt.args) + } + }) + } +} + +// Verifies xid burst describe renders the field/value table +func TestXIDBurstDescribeTable(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/xid/bursts/burst-1" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"burstId":"burst-1","nodeUuid":"node-1","hostname":"gpu-01","nodeGroup":"ng","nodeGroupId":"ng-1","computeZone":"cz","computeZoneId":"cz-1","startTime":"2026-05-01T00:00:00Z","endTime":"2026-05-01T00:05:00Z","burstDurationSeconds":300,"xidCount":2,"xidNumbers":[{"xidNumber":48,"mnemonic":"DBE","description":"Double Bit ECC"}],"deviceIds":{"0000:0f:00.0":[48,94]},"jobDisruption":true,"category":"GPU","subcategory":"Memory","stickyXidsSuppressed":1,"suggestedActions":[{"action":"Drain the node","code":"DRAIN","persona":"tenant","type":"immediate"}]}`)) + })) + defer server.Close() + + saveTestConfig(t, server.URL, "test-key") + + var out bytes.Buffer + cmd := newRootCmd() + cmd.SetOut(&out) + cmd.SetArgs([]string{"xid", "burst", "describe", "burst-1"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("command failed: %v", err) + } + + got := out.String() + for _, want := range []string{ + "FIELD", "VALUE", "BURST ID", "burst-1", "NODE UUID", "HOSTNAME", "gpu-01", + "NODE GROUP", "COMPUTE ZONE", "DURATION (S)", "300", "XIDS", "48", + "JOB DISRUPTION", "true", "CATEGORY", "GPU", "SUBCATEGORY", "Memory", + "XID 48", "DBE: Double Bit ECC", "DEVICE 0000:0f:00.0", "48, 94", + "TENANT IMMEDIATE", "DRAIN: Drain the node", + } { + if !strings.Contains(got, want) { + t.Fatalf("output missing %q:\n%s", want, got) + } + } + // A field the backend omitted for this persona renders as "-", not "false". + if !strings.Contains(got, "PLATFORM JOB DISRUPTION") || !strings.Contains(got, "PLATFORM JOB DISRUPTION -") { + t.Fatalf("expected omitted platform disruption to render as '-':\n%s", got) + } +} + +// Verifies xid burst describe JSON output is the raw backend payload +func TestXIDBurstDescribeJSON(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + body := `{"burstId":"burst-1","hostname":"gpu-01","jobDisruption":true}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + + saveTestConfig(t, server.URL, "test-key") + + var out bytes.Buffer + cmd := newRootCmd() + cmd.SetOut(&out) + cmd.SetArgs([]string{"xid", "burst", "describe", "burst-1", "--output", "json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("command failed: %v", err) + } + if strings.TrimSpace(out.String()) != body { + t.Fatalf("unexpected JSON:\ngot %s\nwant %s", strings.TrimSpace(out.String()), body) + } +} + +// Verifies xid burst describe requires exactly one burst ID +func TestXIDBurstDescribeRequiresBurstID(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatal("did not expect a request for invalid args") + })) + defer server.Close() + + saveTestConfig(t, server.URL, "test-key") + + for _, args := range [][]string{ + {"xid", "burst", "describe"}, + {"xid", "burst", "describe", "burst-1", "burst-2"}, + } { + cmd := newRootCmd() + cmd.SetOut(new(bytes.Buffer)) + cmd.SetArgs(args) + if err := cmd.Execute(); err == nil { + t.Fatalf("expected error for %v", args) + } + } +} + +// Verifies --all merges pages using the derived has-more signal +func TestXIDBurstListAllFetchesEveryPage(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + pages := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + pages++ + w.Header().Set("Content-Type", "application/json") + switch page { + case "0": + _, _ = w.Write([]byte(`{"items":[{"burstId":"burst-1"}],"page":0,"pageSize":1,"total":2}`)) + case "1": + _, _ = w.Write([]byte(`{"items":[{"burstId":"burst-2"}],"page":1,"pageSize":1,"total":2}`)) + default: + t.Fatalf("unexpected page: %q", page) + } + })) + defer server.Close() + + saveTestConfig(t, server.URL, "test-key") + + var out bytes.Buffer + cmd := newRootCmd() + cmd.SetOut(&out) + cmd.SetArgs([]string{"xid", "burst", "list", "--window", "24h", "--all", "--page-size", "1"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("command failed: %v", err) + } + if pages != 2 { + t.Fatalf("expected 2 API pages, got %d", pages) + } + got := out.String() + if !strings.Contains(got, "burst-1") || !strings.Contains(got, "burst-2") { + t.Fatalf("expected both pages in output:\n%s", got) + } +} + +// Verifies the derived has-more signal, which the endpoint does not report +func TestXIDBurstPageHasMore(t *testing.T) { + tests := []struct { + name string + page int + pageSize int + total int + want bool + }{ + {"more pages remain", 0, 20, 45, true}, + {"last page", 2, 20, 45, false}, + {"exact fit", 1, 20, 40, false}, + {"empty result", 0, 20, 0, false}, + {"unreported page size", 0, 0, 45, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := xidBurstPageHasMore(nvfleetint.XIDBurstsPage{ + Page: tt.page, + PageSize: tt.pageSize, + Total: tt.total, + }) + if got != tt.want { + t.Fatalf("hasMore = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/docs/cli.md b/docs/cli.md index 7e47ba0..0ca96a9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -126,6 +126,11 @@ nvfleetint alert options --view historical nvfleetint event list --window 24h nvfleetint event buckets --window 24h +# XID bursts +nvfleetint xid burst list --window 24h +nvfleetint xid burst list --window 168h --xid-numbers 48,94 --job-disruption +nvfleetint xid burst describe + # Tags and reports nvfleetint tag list --prefix gpu nvfleetint report inventory @@ -149,6 +154,13 @@ complete event timeline. `alert list` separately provides the fleet-wide flat alert records. `alert summary`, `alert node`, and `alert options` default to the active view; pass `--view historical` for history. +`xid burst list` requires a time range (`--window`, or `--start` and `--end`), +which applies to each burst's start time. Some of its filters and response +fields — category, subcategory, platform-attributed disruption, XID +descriptions, and DC-admin actions — are available only to cloud-provider/NCP +callers. `xid burst describe` adds the impacted GPU device IDs, per-XID catalog +details, and suggested actions. + ## CSV reports Write an inventory report to a file: diff --git a/internal/output/output.go b/internal/output/output.go index f680f9e..227abc2 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -113,6 +113,15 @@ func FormatOptionalInt(value *int) string { return strconv.Itoa(*value) } +// Formats optional booleans for table output. A nil value renders as "-" so a +// field the backend omitted is distinguishable from one reported as false. +func FormatOptionalBool(value *bool) string { + if value == nil { + return "-" + } + return strconv.FormatBool(*value) +} + // Formats optional percentage values for table output func FormatOptionalPercentage(value *float32) string { if value == nil { diff --git a/internal/output/output_test.go b/internal/output/output_test.go index f5fc389..7e1db80 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -78,6 +78,8 @@ func TestFormatHelpers(t *testing.T) { percentage := float32(95) percentageDecimal := float32(95.5) zeroPercentage := float32(0) + enabled := true + disabled := false location := &nvfleetint.GeoLocation{City: "Santa Clara", Country: "US"} regionalLocation := &nvfleetint.GeoLocation{Region: "us-west-1", City: "Ignored"} @@ -99,6 +101,9 @@ func TestFormatHelpers(t *testing.T) { {name: "name or id", got: FormatNameOrID("", "ng-1"), want: "ng-1"}, {name: "optional int", got: FormatOptionalInt(&count), want: "7"}, {name: "optional int nil", got: FormatOptionalInt(nil), want: "-"}, + {name: "optional bool true", got: FormatOptionalBool(&enabled), want: "true"}, + {name: "optional bool false", got: FormatOptionalBool(&disabled), want: "false"}, + {name: "optional bool nil", got: FormatOptionalBool(nil), want: "-"}, {name: "optional percentage", got: FormatOptionalPercentage(&percentage), want: "95%"}, {name: "optional percentage decimal", got: FormatOptionalPercentage(&percentageDecimal), want: "95.5%"}, {name: "optional percentage zero", got: FormatOptionalPercentage(&zeroPercentage), want: "0%"}, diff --git a/nvfleetint/common.go b/nvfleetint/common.go index d813e7b..f689927 100644 --- a/nvfleetint/common.go +++ b/nvfleetint/common.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/NVIDIA/fleet-intelligence-client/internal/generated/fleetapi" ) @@ -143,7 +144,7 @@ func cloneFloat32(value *float32) *float32 { return &out } -// Copies an optional boolean +// Copies optional booleans without sharing pointers func cloneBool(value *bool) *bool { if value == nil { return nil @@ -152,6 +153,15 @@ func cloneBool(value *bool) *bool { return &out } +// Converts optional timestamps into RFC3339 strings without dropping fractional +// seconds, matching the string timestamps the other SDK models expose. +func timeValue(value *time.Time) string { + if value == nil { + return "" + } + return value.Format(time.RFC3339Nano) +} + // Copies optional string slices without sharing backing arrays func cloneStringSlice(values *[]string) []string { if values == nil { @@ -160,6 +170,24 @@ func cloneStringSlice(values *[]string) []string { return append([]string(nil), (*values)...) } +// Converts a slice into an optional query parameter, omitting it when empty +func optionalStringSlice(values []string) *[]string { + if len(values) == 0 { + return nil + } + out := append([]string(nil), values...) + return &out +} + +// Converts a string into an optional query parameter, omitting it when blank +func optionalTrimmedString(value string) *string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + return &trimmed +} + // Returns a pointer to a copied boolean value func boolPointer(value bool) *bool { out := value diff --git a/nvfleetint/xid.go b/nvfleetint/xid.go new file mode 100644 index 0000000..2114cce --- /dev/null +++ b/nvfleetint/xid.go @@ -0,0 +1,334 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package nvfleetint + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/NVIDIA/fleet-intelligence-client/internal/generated/fleetapi" +) + +const ( + XIDBurstSortByJobDisruption XIDBurstSortBy = "jobDisruption" + XIDBurstSortByJobDisruptionDueToPlatformIssue XIDBurstSortBy = "jobDisruptionDueToPlatformIssue" + XIDBurstSortByCategory XIDBurstSortBy = "category" + XIDBurstSortBySubcategory XIDBurstSortBy = "subcategory" + XIDBurstSortByXIDNumbers XIDBurstSortBy = "xidNumbers" + XIDBurstSortByHostname XIDBurstSortBy = "hostname" + XIDBurstSortByNodeGroup XIDBurstSortBy = "nodeGroup" + XIDBurstSortByComputeZone XIDBurstSortBy = "computeZone" + XIDBurstSortByStartTime XIDBurstSortBy = "startTime" + XIDBurstSortByDCAdminAction XIDBurstSortBy = "dcAdminAction" + XIDBurstSortByDCAdminInvestigation XIDBurstSortBy = "dcAdminInvestigation" + XIDBurstSortByTenantAction XIDBurstSortBy = "tenantAction" + XIDBurstSortByTenantInvestigation XIDBurstSortBy = "tenantInvestigation" + + XIDBurstOrderAsc XIDBurstSortOrder = "asc" + XIDBurstOrderDesc XIDBurstSortOrder = "desc" +) + +// XIDBurstSortBy represents a sortable XID burst column +type XIDBurstSortBy string + +// Valid reports whether the sort field is accepted by the API +func (sortBy XIDBurstSortBy) Valid() bool { + return fleetapi.GetV1XIDBurstsParamsSortBy(sortBy).Valid() +} + +// XIDBurstSortOrder represents a sort direction for XID burst listings +type XIDBurstSortOrder string + +// Valid reports whether the sort order is accepted by the API +func (order XIDBurstSortOrder) Valid() bool { + return fleetapi.GetV1XIDBurstsParamsSortOrder(order).Valid() +} + +// ListXIDBurstsOptions represents request options for listing finalized XID +// bursts. A time range is required: supply either Window (relative) or +// StartTime and EndTime (absolute), but not both. Values within each slice +// filter are OR-combined; filters of different kinds are AND-combined. +type ListXIDBurstsOptions struct { + Window string + StartTime string + EndTime string + + NodeUUID string + NodeGroupIDs []string + ComputeZoneIDs []string + + JobDisruption *bool + JobDisruptionDueToPlatformIssue *bool + XIDNumbers []int + + HostnameSearch string + CategorySearch string + SubcategorySearch string + TenantActionSearch string + TenantInvestigationSearch string + DCAdminActionSearch string + DCAdminInvestigationSearch string + + Categories []string + Subcategories []string + TenantActions []string + TenantInvestigations []string + DCAdminActions []string + DCAdminInvestigations []string + + SortBy XIDBurstSortBy + SortOrder XIDBurstSortOrder + Page *int + PageSize *int +} + +// XIDBurstsPage represents a paginated XID burst list with the raw backend payload +type XIDBurstsPage struct { + Bursts []XIDBurst `json:"items"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` + RawJSON []byte `json:"-"` +} + +// XIDBurst represents one finalized XID burst. Fields are shaped server-side by +// the caller's persona: category, subcategory, platform-attributed disruption, +// XID descriptions, and DC-admin actions are omitted for tenant callers. +type XIDBurst struct { + BurstID string `json:"burstId,omitempty"` + NodeUUID string `json:"nodeUuid,omitempty"` + Hostname string `json:"hostname,omitempty"` + NodeGroup string `json:"nodeGroup,omitempty"` + NodeGroupID string `json:"nodeGroupId,omitempty"` + ComputeZone string `json:"computeZone,omitempty"` + ComputeZoneID string `json:"computeZoneId,omitempty"` + StartTime string `json:"startTime,omitempty"` + EndTime string `json:"endTime,omitempty"` + BurstDurationSeconds *int `json:"burstDurationSeconds,omitempty"` + XIDCount *int `json:"xidCount,omitempty"` + XIDNumbers []XIDBurstXID `json:"xidNumbers,omitempty"` + DeviceIDs map[string][]int `json:"deviceIds,omitempty"` + JobDisruption *bool `json:"jobDisruption,omitempty"` + JobDisruptionDueToPlatformIssue *bool `json:"jobDisruptionDueToPlatformIssue,omitempty"` + Category string `json:"category,omitempty"` + Subcategory string `json:"subcategory,omitempty"` + StickyXIDsSuppressed *int `json:"stickyXidsSuppressed,omitempty"` + SuggestedActions []SuggestedAction `json:"suggestedActions,omitempty"` +} + +// XIDBurstDetails represents one XID burst with the raw backend payload +type XIDBurstDetails struct { + XIDBurst + RawJSON []byte `json:"-"` +} + +// XIDBurstXID represents one XID number observed in a burst +type XIDBurstXID struct { + XIDNumber *int `json:"xidNumber,omitempty"` + Mnemonic string `json:"mnemonic,omitempty"` + Description string `json:"description,omitempty"` +} + +// ListXIDBursts retrieves finalized XID bursts for the selected time range +func (c *Client) ListXIDBursts(ctx context.Context, opts ListXIDBurstsOptions) (XIDBurstsPage, error) { + ctx, cancel := c.requestContext(ctx) + defer cancel() + + if err := validateListXIDBurstsOptions(opts); err != nil { + return XIDBurstsPage{}, err + } + timeRange, err := normalizeEventTimeRange(opts.Window, opts.StartTime, opts.EndTime) + if err != nil { + return XIDBurstsPage{}, err + } + + mode := fleetapi.GetV1XIDBurstsParamsTimeMode(timeRange.timeMode) + params := fleetapi.GetV1XIDBurstsParams{TimeMode: &mode} + if timeRange.window != "" { + value := timeRange.window + params.Window = &value + } + if timeRange.startTime != "" { + value := timeRange.startTime + params.StartTime = &value + } + if timeRange.endTime != "" { + value := timeRange.endTime + params.EndTime = &value + } + if node := strings.TrimSpace(opts.NodeUUID); node != "" { + params.NodeUUID = &node + } + params.NodeGroupIds = optionalStringSlice(opts.NodeGroupIDs) + params.ComputeZoneIds = optionalStringSlice(opts.ComputeZoneIDs) + params.JobDisruption = cloneBool(opts.JobDisruption) + params.JobDisruptionDueToPlatformIssue = cloneBool(opts.JobDisruptionDueToPlatformIssue) + if len(opts.XIDNumbers) > 0 { + values := append([]int(nil), opts.XIDNumbers...) + params.XidNumbers = &values + } + params.HostnameSearch = optionalTrimmedString(opts.HostnameSearch) + params.CategorySearch = optionalTrimmedString(opts.CategorySearch) + params.SubcategorySearch = optionalTrimmedString(opts.SubcategorySearch) + params.TenantActionSearch = optionalTrimmedString(opts.TenantActionSearch) + params.TenantInvestigationSearch = optionalTrimmedString(opts.TenantInvestigationSearch) + params.DcAdminActionSearch = optionalTrimmedString(opts.DCAdminActionSearch) + params.DcAdminInvestigationSearch = optionalTrimmedString(opts.DCAdminInvestigationSearch) + params.Categories = optionalStringSlice(opts.Categories) + params.Subcategories = optionalStringSlice(opts.Subcategories) + params.TenantActions = optionalStringSlice(opts.TenantActions) + params.TenantInvestigations = optionalStringSlice(opts.TenantInvestigations) + params.DcAdminActions = optionalStringSlice(opts.DCAdminActions) + params.DcAdminInvestigations = optionalStringSlice(opts.DCAdminInvestigations) + if opts.SortBy != "" { + sortBy := fleetapi.GetV1XIDBurstsParamsSortBy(opts.SortBy) + params.SortBy = &sortBy + } + if opts.SortOrder != "" { + sortOrder := fleetapi.GetV1XIDBurstsParamsSortOrder(opts.SortOrder) + params.SortOrder = &sortOrder + } + if opts.Page != nil { + params.Page = cloneInt(opts.Page) + } + if opts.PageSize != nil { + params.PageSize = cloneInt(opts.PageSize) + } + + resp, err := c.api.GetV1XIDBurstsWithResponse(ctx, ¶ms) + if err != nil { + return XIDBurstsPage{}, err + } + if resp.StatusCode() != http.StatusOK { + return XIDBurstsPage{}, newAPIError(resp.StatusCode(), resp.Status(), resp.Body) + } + + var data fleetapi.ModelsXIDBurstsListResponse + if err := json.Unmarshal(resp.Body, &data); err != nil { + return XIDBurstsPage{}, err + } + + page := XIDBurstsPage{ + Page: intValue(data.Page), + PageSize: intValue(data.PageSize), + Total: intValue(data.Total), + RawJSON: append([]byte(nil), resp.Body...), + } + if data.Items != nil { + page.Bursts = make([]XIDBurst, 0, len(*data.Items)) + for _, item := range *data.Items { + page.Bursts = append(page.Bursts, xidBurstFromGenerated(item)) + } + } + return page, nil +} + +// DescribeXIDBurst retrieves the details of one finalized XID burst +func (c *Client) DescribeXIDBurst(ctx context.Context, burstID string) (XIDBurstDetails, error) { + ctx, cancel := c.requestContext(ctx) + defer cancel() + + burstID = strings.TrimSpace(burstID) + if burstID == "" { + return XIDBurstDetails{}, fmt.Errorf("burst ID is required") + } + + resp, err := c.api.GetV1XIDBurstDetailWithResponse(ctx, burstID) + if err != nil { + return XIDBurstDetails{}, err + } + if resp.StatusCode() != http.StatusOK { + return XIDBurstDetails{}, newAPIError(resp.StatusCode(), resp.Status(), resp.Body) + } + + var data fleetapi.ModelsXIDBurstDetail + if err := json.Unmarshal(resp.Body, &data); err != nil { + return XIDBurstDetails{}, err + } + + return XIDBurstDetails{ + XIDBurst: xidBurstDetailFromGenerated(data), + RawJSON: append([]byte(nil), resp.Body...), + }, nil +} + +// Checks XID burst list options before making the request +func validateListXIDBurstsOptions(opts ListXIDBurstsOptions) error { + if opts.SortBy != "" && !opts.SortBy.Valid() { + return fmt.Errorf("invalid XID burst sort %q", opts.SortBy) + } + if opts.SortOrder != "" && !opts.SortOrder.Valid() { + return fmt.Errorf("invalid XID burst sort order %q: expected asc or desc", opts.SortOrder) + } + for _, xid := range opts.XIDNumbers { + if xid < 0 { + return fmt.Errorf("invalid XID number %d: expected a non-negative integer", xid) + } + } + return nil +} + +// Maps XID burst list items into SDK values +func xidBurstFromGenerated(item fleetapi.ModelsXIDBurstListItem) XIDBurst { + return XIDBurst{ + BurstID: stringValue(item.BurstId), + NodeUUID: stringValue(item.NodeUuid), + Hostname: stringValue(item.Hostname), + NodeGroup: stringValue(item.NodeGroup), + NodeGroupID: stringValue(item.NodeGroupId), + ComputeZone: stringValue(item.ComputeZone), + ComputeZoneID: stringValue(item.ComputeZoneId), + StartTime: timeValue(item.StartTime), + EndTime: timeValue(item.EndTime), + BurstDurationSeconds: cloneInt(item.BurstDurationSeconds), + XIDCount: cloneInt(item.XidCount), + XIDNumbers: xidBurstXIDsFromGenerated(item.XidNumbers), + DeviceIDs: xidBurstDeviceIDsFromGenerated(item.DeviceIds), + JobDisruption: cloneBool(item.JobDisruption), + JobDisruptionDueToPlatformIssue: cloneBool(item.JobDisruptionDueToPlatformIssue), + Category: stringValue(item.Category), + Subcategory: stringValue(item.Subcategory), + StickyXIDsSuppressed: cloneInt(item.StickyXidsSuppressed), + SuggestedActions: suggestedActionsFromGenerated(item.SuggestedActions), + } +} + +// Maps XID burst details into SDK values. The generated detail and list-item +// models are field-identical, so the conversion below reuses the list-item +// mapper; if the two ever diverge in the spec, this stops compiling instead of +// silently dropping a field. +func xidBurstDetailFromGenerated(detail fleetapi.ModelsXIDBurstDetail) XIDBurst { + return xidBurstFromGenerated(fleetapi.ModelsXIDBurstListItem(detail)) +} + +// Maps burst XID entries into SDK values +func xidBurstXIDsFromGenerated(xids *[]fleetapi.ModelsXIDBurstXID) []XIDBurstXID { + if xids == nil { + return nil + } + out := make([]XIDBurstXID, 0, len(*xids)) + for _, xid := range *xids { + out = append(out, XIDBurstXID{ + XIDNumber: cloneInt(xid.XidNumber), + Mnemonic: stringValue(xid.Mnemonic), + Description: stringValue(xid.Description), + }) + } + return out +} + +// Copies the impacted-device map without sharing backing storage +func xidBurstDeviceIDsFromGenerated(devices *map[string][]int) map[string][]int { + if devices == nil { + return nil + } + out := make(map[string][]int, len(*devices)) + for device, xids := range *devices { + out[device] = append([]int(nil), xids...) + } + return out +} diff --git a/nvfleetint/xid_test.go b/nvfleetint/xid_test.go new file mode 100644 index 0000000..38b32cd --- /dev/null +++ b/nvfleetint/xid_test.go @@ -0,0 +1,304 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package nvfleetint + +import ( + "context" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" +) + +const xidBurstBody = `{"items":[{"burstId":"burst-1","nodeUuid":"node-1","hostname":"gpu-01","nodeGroup":"ng","nodeGroupId":"ng-1","computeZone":"cz","computeZoneId":"cz-1","startTime":"2026-05-01T00:00:00.123456789Z","endTime":"2026-05-01T00:05:00.987654321Z","burstDurationSeconds":300,"xidCount":2,"xidNumbers":[{"xidNumber":48,"mnemonic":"DBE","description":"Double Bit ECC"},{"xidNumber":94,"mnemonic":"CE"}],"deviceIds":{"0000:0f:00.0":[48,94]},"jobDisruption":true,"jobDisruptionDueToPlatformIssue":false,"category":"GPU","subcategory":"Memory","stickyXidsSuppressed":1,"suggestedActions":[{"action":"Drain the node","code":"DRAIN","persona":"tenant","type":"immediate"}]}],"page":0,"pageSize":20,"total":1}` + +// Verifies XID burst list request construction and decoding +func TestListXIDBurstsSendsParamsAndDecodes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/xid/bursts" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Fatalf("unexpected auth header: %q", got) + } + query := r.URL.Query() + for _, want := range []struct{ key, value string }{ + {"timeMode", "relative"}, + {"window", "24h"}, + {"nodeUUID", "node-1"}, + {"hostnameSearch", "gpu-0"}, + {"jobDisruption", "true"}, + {"jobDisruptionDueToPlatformIssue", "false"}, + {"sortBy", "startTime"}, + {"sortOrder", "desc"}, + {"page", "1"}, + {"pageSize", "20"}, + {"tenantActionSearch", "DRAIN"}, + {"dcAdminInvestigationSearch", "LOGS"}, + } { + if got := query.Get(want.key); got != want.value { + t.Fatalf("unexpected %s: %q", want.key, got) + } + } + for _, want := range []struct { + key string + values []string + }{ + {"xidNumbers", []string{"48", "94"}}, + {"nodeGroupIds", []string{"ng-1"}}, + {"computeZoneIds", []string{"cz-1"}}, + {"categories", []string{"GPU"}}, + {"tenantActions", []string{"DRAIN", "RESET"}}, + } { + if got := query[want.key]; !reflect.DeepEqual(got, want.values) { + t.Fatalf("unexpected %s: %#v", want.key, got) + } + } + if query.Has("startTime") || query.Has("endTime") { + t.Fatalf("did not expect start/end in relative mode: %q", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(xidBurstBody)) + })) + defer server.Close() + + client, err := NewClient(server.URL, "test-key") + if err != nil { + t.Fatalf("new client failed: %v", err) + } + + page := 1 + pageSize := 20 + jobDisruption := true + platformIssue := false + result, err := client.ListXIDBursts(context.Background(), ListXIDBurstsOptions{ + Window: "24h", + NodeUUID: "node-1", + NodeGroupIDs: []string{"ng-1"}, + ComputeZoneIDs: []string{"cz-1"}, + JobDisruption: &jobDisruption, + JobDisruptionDueToPlatformIssue: &platformIssue, + XIDNumbers: []int{48, 94}, + HostnameSearch: "gpu-0", + TenantActionSearch: "DRAIN", + DCAdminInvestigationSearch: "LOGS", + Categories: []string{"GPU"}, + TenantActions: []string{"DRAIN", "RESET"}, + SortBy: XIDBurstSortByStartTime, + SortOrder: XIDBurstOrderDesc, + Page: &page, + PageSize: &pageSize, + }) + if err != nil { + t.Fatalf("list XID bursts failed: %v", err) + } + + if len(result.Bursts) != 1 { + t.Fatalf("unexpected bursts: %#v", result.Bursts) + } + burst := result.Bursts[0] + if burst.BurstID != "burst-1" || burst.NodeUUID != "node-1" || burst.Hostname != "gpu-01" { + t.Fatalf("unexpected burst identity: %#v", burst) + } + if burst.StartTime != "2026-05-01T00:00:00.123456789Z" || burst.EndTime != "2026-05-01T00:05:00.987654321Z" { + t.Fatalf("unexpected burst times: %#v", burst) + } + if burst.JobDisruption == nil || !*burst.JobDisruption { + t.Fatalf("unexpected jobDisruption: %#v", burst.JobDisruption) + } + if burst.JobDisruptionDueToPlatformIssue == nil || *burst.JobDisruptionDueToPlatformIssue { + t.Fatalf("unexpected platform disruption: %#v", burst.JobDisruptionDueToPlatformIssue) + } + if len(burst.XIDNumbers) != 2 || burst.XIDNumbers[0].XIDNumber == nil || *burst.XIDNumbers[0].XIDNumber != 48 || + burst.XIDNumbers[0].Mnemonic != "DBE" || burst.XIDNumbers[0].Description != "Double Bit ECC" { + t.Fatalf("unexpected XID numbers: %#v", burst.XIDNumbers) + } + if !reflect.DeepEqual(burst.DeviceIDs, map[string][]int{"0000:0f:00.0": {48, 94}}) { + t.Fatalf("unexpected device IDs: %#v", burst.DeviceIDs) + } + if len(burst.SuggestedActions) != 1 || burst.SuggestedActions[0].Code != "DRAIN" || + burst.SuggestedActions[0].Persona != "tenant" || burst.SuggestedActions[0].Type != "immediate" { + t.Fatalf("unexpected suggested actions: %#v", burst.SuggestedActions) + } + if result.Page != 0 || result.PageSize != 20 || result.Total != 1 { + t.Fatalf("unexpected pagination: %#v", result) + } + if !strings.Contains(string(result.RawJSON), `"burstId"`) { + t.Fatalf("raw JSON not preserved: %q", string(result.RawJSON)) + } +} + +// Verifies an absolute range sets absolute mode and omits the window +func TestListXIDBurstsAbsoluteRange(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + if got := query.Get("timeMode"); got != "absolute" { + t.Fatalf("unexpected timeMode: %q", got) + } + if got := query.Get("startTime"); got != "2026-05-01T00:00:00Z" { + t.Fatalf("unexpected startTime: %q", got) + } + if got := query.Get("endTime"); got != "2026-05-08T00:00:00Z" { + t.Fatalf("unexpected endTime: %q", got) + } + if query.Has("window") { + t.Fatalf("did not expect window in absolute mode: %q", r.URL.RawQuery) + } + // Filters the caller left unset must not be sent at all. + for _, key := range []string{"jobDisruption", "xidNumbers", "categories", "sortBy", "hostnameSearch"} { + if query.Has(key) { + t.Fatalf("did not expect %s: %q", key, r.URL.RawQuery) + } + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[],"page":0,"pageSize":20,"total":0}`)) + })) + defer server.Close() + + client, err := NewClient(server.URL, "test-key") + if err != nil { + t.Fatalf("new client failed: %v", err) + } + + if _, err := client.ListXIDBursts(context.Background(), ListXIDBurstsOptions{ + StartTime: "2026-05-01T00:00:00Z", + EndTime: "2026-05-08T00:00:00Z", + }); err != nil { + t.Fatalf("list XID bursts failed: %v", err) + } +} + +// Verifies invalid list options are rejected before any request +func TestListXIDBurstsValidation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatal("did not expect a request for invalid input") + })) + defer server.Close() + + client, err := NewClient(server.URL, "test-key") + if err != nil { + t.Fatalf("new client failed: %v", err) + } + + tests := []struct { + name string + opts ListXIDBurstsOptions + want string + }{ + {"no time range", ListXIDBurstsOptions{}, "a time range is required"}, + {"window with start", ListXIDBurstsOptions{Window: "24h", StartTime: "2026-05-01T00:00:00Z"}, "window cannot be combined"}, + {"malformed start", ListXIDBurstsOptions{StartTime: "yesterday", EndTime: "2026-05-08T00:00:00Z"}, "start time must be RFC3339"}, + {"bad sort", ListXIDBurstsOptions{Window: "24h", SortBy: "burstId"}, "invalid XID burst sort"}, + {"bad order", ListXIDBurstsOptions{Window: "24h", SortOrder: "sideways"}, "invalid XID burst sort order"}, + {"negative XID", ListXIDBurstsOptions{Window: "24h", XIDNumbers: []int{-1}}, "invalid XID number"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := client.ListXIDBursts(context.Background(), tt.opts); err == nil { + t.Fatal("expected error") + } else if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +// Verifies XID burst list API errors are structured +func TestListXIDBurstsReturnsAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden","details":"NCP-only filter requested by tenant"}`)) + })) + defer server.Close() + + client, err := NewClient(server.URL, "test-key") + if err != nil { + t.Fatalf("new client failed: %v", err) + } + + _, err = client.ListXIDBursts(context.Background(), ListXIDBurstsOptions{Window: "24h"}) + if err == nil { + t.Fatal("expected API error") + } + if !strings.Contains(err.Error(), "403") || !strings.Contains(err.Error(), "NCP-only filter") { + t.Fatalf("unexpected error: %v", err) + } +} + +// Verifies XID burst describe request construction and decoding +func TestDescribeXIDBurstSendsRequestAndDecodes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/xid/bursts/burst-1" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Fatalf("unexpected auth header: %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"burstId":"burst-1","nodeUuid":"node-1","hostname":"gpu-01","startTime":"2026-05-01T00:00:00Z","endTime":"2026-05-01T00:05:00Z","burstDurationSeconds":300,"xidCount":1,"xidNumbers":[{"xidNumber":48,"mnemonic":"DBE"}],"deviceIds":{"0000:0f:00.0":[48]},"jobDisruption":true,"suggestedActions":[{"action":"Drain the node","code":"DRAIN","persona":"tenant","type":"immediate"}]}`)) + })) + defer server.Close() + + client, err := NewClient(server.URL, "test-key") + if err != nil { + t.Fatalf("new client failed: %v", err) + } + + burst, err := client.DescribeXIDBurst(context.Background(), "burst-1") + if err != nil { + t.Fatalf("describe XID burst failed: %v", err) + } + if burst.BurstID != "burst-1" || burst.Hostname != "gpu-01" || burst.StartTime != "2026-05-01T00:00:00Z" { + t.Fatalf("unexpected burst: %#v", burst) + } + if burst.JobDisruption == nil || !*burst.JobDisruption { + t.Fatalf("unexpected jobDisruption: %#v", burst.JobDisruption) + } + // A field the backend omits for this persona stays nil rather than false. + if burst.JobDisruptionDueToPlatformIssue != nil { + t.Fatalf("expected omitted platform disruption, got %#v", burst.JobDisruptionDueToPlatformIssue) + } + if len(burst.SuggestedActions) != 1 || burst.SuggestedActions[0].Action != "Drain the node" { + t.Fatalf("unexpected suggested actions: %#v", burst.SuggestedActions) + } + if !strings.Contains(string(burst.RawJSON), `"deviceIds"`) { + t.Fatalf("raw JSON not preserved: %q", string(burst.RawJSON)) + } +} + +// Verifies describe requires a burst ID and reports API errors +func TestDescribeXIDBurstErrors(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"not found","details":"finalized burst not found"}`)) + })) + defer server.Close() + + client, err := NewClient(server.URL, "test-key") + if err != nil { + t.Fatalf("new client failed: %v", err) + } + + if _, err := client.DescribeXIDBurst(context.Background(), " "); err == nil { + t.Fatal("expected error for empty burst ID") + } else if !strings.Contains(err.Error(), "burst ID is required") { + t.Fatalf("unexpected error: %v", err) + } + if requests != 0 { + t.Fatalf("expected no requests for invalid input, got %d", requests) + } + + _, err = client.DescribeXIDBurst(context.Background(), "missing") + if err == nil { + t.Fatal("expected API error") + } + if !strings.Contains(err.Error(), "404") || !strings.Contains(err.Error(), "finalized burst not found") { + t.Fatalf("unexpected error: %v", err) + } +} From f0a518767871b8b2e5d0c8a94662daf03171093c Mon Sep 17 00:00:00 2001 From: Emily Zhang Date: Fri, 14 Aug 2026 09:50:54 -0700 Subject: [PATCH 2/4] refactor(xid)!: rename xid burst to xidburst Collapse the xid command group, whose only child was burst, into a single top-level xidburst command. The subcommands are unchanged: nvfleetint xidburst list and nvfleetint xidburst describe. This is a breaking rename with no alias; nvfleetint xid burst no longer resolves. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Emily Zhang --- cmd/nvfleetint/root.go | 2 +- cmd/nvfleetint/xid.go | 41 +++++++++++++------------------------- cmd/nvfleetint/xid_test.go | 38 +++++++++++++++++------------------ docs/cli.md | 10 +++++----- 4 files changed, 39 insertions(+), 52 deletions(-) diff --git a/cmd/nvfleetint/root.go b/cmd/nvfleetint/root.go index 4100f29..02572ae 100644 --- a/cmd/nvfleetint/root.go +++ b/cmd/nvfleetint/root.go @@ -70,7 +70,7 @@ func newRootCmd() *cobra.Command { cmd.AddCommand(newNodeCmd()) cmd.AddCommand(newAlertCmd()) cmd.AddCommand(newEventCmd()) - cmd.AddCommand(newXIDCmd()) + cmd.AddCommand(newXIDBurstCmd()) cmd.AddCommand(newReportCmd()) cmd.AddCommand(newTagCmd()) cmd.AddCommand(newVersionCmd()) diff --git a/cmd/nvfleetint/xid.go b/cmd/nvfleetint/xid.go index 87ec911..9bebafb 100644 --- a/cmd/nvfleetint/xid.go +++ b/cmd/nvfleetint/xid.go @@ -17,12 +17,12 @@ import ( "github.com/spf13/cobra" ) -// Lists the sort fields accepted by xid burst list +// Lists the sort fields accepted by xidburst list const xidBurstSortByList = "startTime, hostname, nodeGroup, computeZone, xidNumbers, jobDisruption, " + "jobDisruptionDueToPlatformIssue, category, subcategory, tenantAction, tenantInvestigation, " + "dcAdminAction, or dcAdminInvestigation" -// Stores local flag values for xid burst list +// Stores local flag values for xidburst list type xidBurstListFlags struct { window string start string @@ -50,7 +50,7 @@ type xidBurstListFlags struct { order string } -// Stores data ready for xid burst list rendering +// Stores data ready for xidburst list rendering type xidBurstListOutput struct { Bursts []nvfleetint.XIDBurst JSONValue any @@ -58,30 +58,17 @@ type xidBurstListOutput struct { Page *clioutput.Pagination } -// Creates the top-level xid command group -func newXIDCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "xid", - Short: "Inspect XID diagnostics", - } - - cmd.AddCommand(newXIDBurstCmd()) - rejectUnknownSubcommands(cmd) - - return cmd -} - -// Creates the xid burst command group +// Creates the top-level xidburst command group func newXIDBurstCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "burst", + Use: "xidburst", Short: "Inspect finalized XID bursts", Long: "Inspect finalized XID bursts: groups of XID errors observed together on one node.\n\n" + "Workflow: list → describe. List the bursts in a time range, then describe one for its " + "impacted devices, XID catalog details, and suggested actions.", - Example: " nvfleetint xid burst list --window 24h\n" + - " nvfleetint xid burst list --window 168h --job-disruption --sort-by startTime\n" + - " nvfleetint xid burst describe ", + Example: " nvfleetint xidburst list --window 24h\n" + + " nvfleetint xidburst list --window 168h --job-disruption --sort-by startTime\n" + + " nvfleetint xidburst describe ", } cmd.AddCommand(newXIDBurstListCmd()) @@ -91,7 +78,7 @@ func newXIDBurstCmd() *cobra.Command { return cmd } -// Creates the xid burst list command +// Creates the xidburst list command func newXIDBurstListCmd() *cobra.Command { flags := xidBurstListFlags{} common := newCommonFlags() @@ -108,9 +95,9 @@ A time range is required: use --window for a relative range, or --start and Some filters and response fields are only available to cloud-provider/NCP callers; for tenant callers the backend rejects them.`, - Example: ` nvfleetint xid burst list --window 24h - nvfleetint xid burst list --start 2026-05-01T00:00:00Z --end 2026-05-08T00:00:00Z - nvfleetint xid burst list --window 168h --xid-numbers 48,94 --job-disruption`, + Example: ` nvfleetint xidburst list --window 24h + nvfleetint xidburst list --start 2026-05-01T00:00:00Z --end 2026-05-08T00:00:00Z + nvfleetint xidburst list --window 168h --xid-numbers 48,94 --job-disruption`, RunE: func(cmd *cobra.Command, _ []string) error { return runXIDBurstList(cmd, flags, resolveCommonFlags(cmd, common)) }, @@ -143,7 +130,7 @@ callers; for tenant callers the backend rejects them.`, return cmd } -// Creates the xid burst describe command +// Creates the xidburst describe command func newXIDBurstDescribeCmd() *cobra.Command { common := newCommonFlags() cmd := &cobra.Command{ @@ -332,7 +319,7 @@ func xidBurstPageHasMore(page nvfleetint.XIDBurstsPage) bool { return (page.Page+1)*page.PageSize < page.Total } -// Writes JSON or table output for xid burst list results +// Writes JSON or table output for xidburst list results func writeXIDBurstListOutput(w io.Writer, common resolvedCommonFlags, result xidBurstListOutput) error { if common.output == clioutput.FormatJSON { return writePaginatedListJSON(w, result.RawJSON, result.JSONValue) diff --git a/cmd/nvfleetint/xid_test.go b/cmd/nvfleetint/xid_test.go index 6922949..4634305 100644 --- a/cmd/nvfleetint/xid_test.go +++ b/cmd/nvfleetint/xid_test.go @@ -15,7 +15,7 @@ import ( const xidBurstsBody = `{"items":[{"burstId":"burst-1","nodeUuid":"node-1","hostname":"gpu-01","nodeGroup":"ng","nodeGroupId":"ng-1","computeZone":"cz","computeZoneId":"cz-1","startTime":"2026-05-01T00:00:00Z","endTime":"2026-05-01T00:05:00Z","burstDurationSeconds":300,"xidCount":2,"xidNumbers":[{"xidNumber":48,"mnemonic":"DBE","description":"Double Bit ECC"},{"xidNumber":94,"mnemonic":"CE"}],"deviceIds":{"0000:0f:00.0":[48,94]},"jobDisruption":true,"category":"GPU","subcategory":"Memory","stickyXidsSuppressed":1,"suggestedActions":[{"action":"Drain the node","code":"DRAIN","persona":"tenant","type":"immediate"}]}],"page":0,"pageSize":20,"total":1}` -// Verifies xid burst list table output and filter translation +// Verifies xidburst list table output and filter translation func TestXIDBurstListTable(t *testing.T) { t.Setenv("HOME", t.TempDir()) @@ -50,7 +50,7 @@ func TestXIDBurstListTable(t *testing.T) { cmd := newRootCmd() cmd.SetOut(&out) cmd.SetArgs([]string{ - "xid", "burst", "list", "--window", "24h", "--xid-numbers", "48,94", + "xidburst", "list", "--window", "24h", "--xid-numbers", "48,94", "--job-disruption", "--sort-by", "startTime", }) @@ -77,7 +77,7 @@ func TestXIDBurstListTable(t *testing.T) { } } -// Verifies xid burst list JSON output is the raw payload with a 1-based page +// Verifies xidburst list JSON output is the raw payload with a 1-based page func TestXIDBurstListJSON(t *testing.T) { t.Setenv("HOME", t.TempDir()) @@ -92,7 +92,7 @@ func TestXIDBurstListJSON(t *testing.T) { var out bytes.Buffer cmd := newRootCmd() cmd.SetOut(&out) - cmd.SetArgs([]string{"xid", "burst", "list", "--window", "24h", "--output", "json"}) + cmd.SetArgs([]string{"xidburst", "list", "--window", "24h", "--output", "json"}) if err := cmd.Execute(); err != nil { t.Fatalf("command failed: %v", err) @@ -110,7 +110,7 @@ func TestXIDBurstListJSON(t *testing.T) { } } -// Verifies xid burst list rejects invalid flags before any request +// Verifies xidburst list rejects invalid flags before any request func TestXIDBurstListFlagValidation(t *testing.T) { t.Setenv("HOME", t.TempDir()) @@ -125,12 +125,12 @@ func TestXIDBurstListFlagValidation(t *testing.T) { name string args []string }{ - {"missing time range", []string{"xid", "burst", "list"}}, - {"window with start", []string{"xid", "burst", "list", "--window", "24h", "--start", "2026-05-01T00:00:00Z"}}, - {"bad sort field", []string{"xid", "burst", "list", "--window", "24h", "--sort-by", "burstId"}}, - {"bad order", []string{"xid", "burst", "list", "--window", "24h", "--order", "sideways"}}, - {"non-numeric xid", []string{"xid", "burst", "list", "--window", "24h", "--xid-numbers", "forty-eight"}}, - {"bad output", []string{"xid", "burst", "list", "--window", "24h", "--output", "yaml"}}, + {"missing time range", []string{"xidburst", "list"}}, + {"window with start", []string{"xidburst", "list", "--window", "24h", "--start", "2026-05-01T00:00:00Z"}}, + {"bad sort field", []string{"xidburst", "list", "--window", "24h", "--sort-by", "burstId"}}, + {"bad order", []string{"xidburst", "list", "--window", "24h", "--order", "sideways"}}, + {"non-numeric xid", []string{"xidburst", "list", "--window", "24h", "--xid-numbers", "forty-eight"}}, + {"bad output", []string{"xidburst", "list", "--window", "24h", "--output", "yaml"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -144,7 +144,7 @@ func TestXIDBurstListFlagValidation(t *testing.T) { } } -// Verifies xid burst describe renders the field/value table +// Verifies xidburst describe renders the field/value table func TestXIDBurstDescribeTable(t *testing.T) { t.Setenv("HOME", t.TempDir()) @@ -162,7 +162,7 @@ func TestXIDBurstDescribeTable(t *testing.T) { var out bytes.Buffer cmd := newRootCmd() cmd.SetOut(&out) - cmd.SetArgs([]string{"xid", "burst", "describe", "burst-1"}) + cmd.SetArgs([]string{"xidburst", "describe", "burst-1"}) if err := cmd.Execute(); err != nil { t.Fatalf("command failed: %v", err) @@ -186,7 +186,7 @@ func TestXIDBurstDescribeTable(t *testing.T) { } } -// Verifies xid burst describe JSON output is the raw backend payload +// Verifies xidburst describe JSON output is the raw backend payload func TestXIDBurstDescribeJSON(t *testing.T) { t.Setenv("HOME", t.TempDir()) @@ -202,7 +202,7 @@ func TestXIDBurstDescribeJSON(t *testing.T) { var out bytes.Buffer cmd := newRootCmd() cmd.SetOut(&out) - cmd.SetArgs([]string{"xid", "burst", "describe", "burst-1", "--output", "json"}) + cmd.SetArgs([]string{"xidburst", "describe", "burst-1", "--output", "json"}) if err := cmd.Execute(); err != nil { t.Fatalf("command failed: %v", err) @@ -212,7 +212,7 @@ func TestXIDBurstDescribeJSON(t *testing.T) { } } -// Verifies xid burst describe requires exactly one burst ID +// Verifies xidburst describe requires exactly one burst ID func TestXIDBurstDescribeRequiresBurstID(t *testing.T) { t.Setenv("HOME", t.TempDir()) @@ -224,8 +224,8 @@ func TestXIDBurstDescribeRequiresBurstID(t *testing.T) { saveTestConfig(t, server.URL, "test-key") for _, args := range [][]string{ - {"xid", "burst", "describe"}, - {"xid", "burst", "describe", "burst-1", "burst-2"}, + {"xidburst", "describe"}, + {"xidburst", "describe", "burst-1", "burst-2"}, } { cmd := newRootCmd() cmd.SetOut(new(bytes.Buffer)) @@ -261,7 +261,7 @@ func TestXIDBurstListAllFetchesEveryPage(t *testing.T) { var out bytes.Buffer cmd := newRootCmd() cmd.SetOut(&out) - cmd.SetArgs([]string{"xid", "burst", "list", "--window", "24h", "--all", "--page-size", "1"}) + cmd.SetArgs([]string{"xidburst", "list", "--window", "24h", "--all", "--page-size", "1"}) if err := cmd.Execute(); err != nil { t.Fatalf("command failed: %v", err) diff --git a/docs/cli.md b/docs/cli.md index 0ca96a9..e7173b7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -127,9 +127,9 @@ nvfleetint event list --window 24h nvfleetint event buckets --window 24h # XID bursts -nvfleetint xid burst list --window 24h -nvfleetint xid burst list --window 168h --xid-numbers 48,94 --job-disruption -nvfleetint xid burst describe +nvfleetint xidburst list --window 24h +nvfleetint xidburst list --window 168h --xid-numbers 48,94 --job-disruption +nvfleetint xidburst describe # Tags and reports nvfleetint tag list --prefix gpu @@ -154,11 +154,11 @@ complete event timeline. `alert list` separately provides the fleet-wide flat alert records. `alert summary`, `alert node`, and `alert options` default to the active view; pass `--view historical` for history. -`xid burst list` requires a time range (`--window`, or `--start` and `--end`), +`xidburst list` requires a time range (`--window`, or `--start` and `--end`), which applies to each burst's start time. Some of its filters and response fields — category, subcategory, platform-attributed disruption, XID descriptions, and DC-admin actions — are available only to cloud-provider/NCP -callers. `xid burst describe` adds the impacted GPU device IDs, per-XID catalog +callers. `xidburst describe` adds the impacted GPU device IDs, per-XID catalog details, and suggested actions. ## CSV reports From d19048ba3c9a0502850f7f6384b0149f99673907 Mon Sep 17 00:00:00 2001 From: Emily Zhang Date: Mon, 17 Aug 2026 12:31:27 -0700 Subject: [PATCH 3/4] feat(api)!: sync CLI and SDK with updated XID burst and alert spec Regenerate the client from the updated OpenAPI contract and adapt the SDK and CLI to the changes it carries: - xidburst: add --exclude-nodegroup-ids and --exclude-compute-zone-ids, rejected in both layers when combined with the inclusive filter for the same dimension - xidburst: add the xidCount, burstDurationSeconds, and nodeUuid sort fields, and table-test every sort value against the generated contract - nodes: nodeUUID is now a required (non-pointer) field on the in-band and simple node models, so the mappers assign it directly BREAKING CHANGE: AlertTimelineAlertSortByComponent is removed. The API no longer accepts sortBy=component for node alert timelines, so `alert node --sort-by component` is rejected before the request is issued. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Emily Zhang --- cmd/nvfleetint/alert.go | 4 +- cmd/nvfleetint/alert_test.go | 2 + cmd/nvfleetint/xid.go | 22 +++++++-- cmd/nvfleetint/xid_test.go | 48 ++++++++++++++++++ docs/cli.md | 8 ++- nvfleetint/alert.go | 1 - nvfleetint/alert_test.go | 4 ++ nvfleetint/xid.go | 35 ++++++++++--- nvfleetint/xid_test.go | 95 ++++++++++++++++++++++++++++++++++++ 9 files changed, 203 insertions(+), 16 deletions(-) diff --git a/cmd/nvfleetint/alert.go b/cmd/nvfleetint/alert.go index a86929d..6b21980 100644 --- a/cmd/nvfleetint/alert.go +++ b/cmd/nvfleetint/alert.go @@ -176,7 +176,7 @@ func newAlertNodeCmd() *cobra.Command { } cmd.Flags().StringVar(&flags.view, "view", "", "Alert view: active or historical (default: active)") - cmd.Flags().StringVar(&flags.sortBy, "sort-by", "", "Sort field: component, startTime, or lastUpdate") + cmd.Flags().StringVar(&flags.sortBy, "sort-by", "", "Sort field: startTime or lastUpdate") cmd.Flags().StringVar(&flags.order, "order", "", "Sort order: asc or desc") cmd.Flags().StringVar(&flags.gpuType, "gpu-type", "", "Comma-separated GPU types to filter") cmd.Flags().StringVar(&flags.nodeGroupIDs, "nodegroup-ids", "", "Comma-separated node group IDs to filter") @@ -642,7 +642,7 @@ func validateAlertTimelineFlags(flags alertTimelineFlags, common resolvedCommonF } sortBy := nvfleetint.AlertTimelineAlertSortBy(strings.TrimSpace(flags.sortBy)) if sortBy != "" && !sortBy.Valid() { - return fmt.Errorf("invalid sort-by %q for node alerts: expected component, startTime, or lastUpdate", flags.sortBy) + return fmt.Errorf("invalid sort-by %q for node alerts: expected startTime or lastUpdate", flags.sortBy) } return nil } diff --git a/cmd/nvfleetint/alert_test.go b/cmd/nvfleetint/alert_test.go index 4814054..cb0e406 100644 --- a/cmd/nvfleetint/alert_test.go +++ b/cmd/nvfleetint/alert_test.go @@ -521,6 +521,8 @@ func TestAlertTimelineRejectsInvalidFlags(t *testing.T) { }{ {name: "summary sort", args: []string{"alert", "summary", "--sort-by", "startTime"}, want: "invalid sort-by"}, {name: "node sort", args: []string{"alert", "node", "node-1", "--sort-by", "alert"}, want: "invalid sort-by"}, + // The API dropped the component sort; only startTime and lastUpdate remain. + {name: "node component sort", args: []string{"alert", "node", "node-1", "--sort-by", "component"}, want: "expected startTime or lastUpdate"}, {name: "missing node", args: []string{"alert", "node"}, want: "node UUID is required"}, {name: "state", args: []string{"alert", "summary", "--alert-state", "Triggered"}, want: "invalid alert-state"}, {name: "describe page", args: []string{"alert", "describe", "alert-1", "--node", "node-1", "--page", "2"}, want: "requires --page-size"}, diff --git a/cmd/nvfleetint/xid.go b/cmd/nvfleetint/xid.go index 9bebafb..cb1bfa1 100644 --- a/cmd/nvfleetint/xid.go +++ b/cmd/nvfleetint/xid.go @@ -4,6 +4,7 @@ package main import ( + "errors" "fmt" "io" "sort" @@ -18,9 +19,9 @@ import ( ) // Lists the sort fields accepted by xidburst list -const xidBurstSortByList = "startTime, hostname, nodeGroup, computeZone, xidNumbers, jobDisruption, " + - "jobDisruptionDueToPlatformIssue, category, subcategory, tenantAction, tenantInvestigation, " + - "dcAdminAction, or dcAdminInvestigation" +const xidBurstSortByList = "startTime, burstDurationSeconds, hostname, nodeUuid, nodeGroup, computeZone, " + + "xidNumbers, xidCount, jobDisruption, jobDisruptionDueToPlatformIssue, category, subcategory, " + + "tenantAction, tenantInvestigation, dcAdminAction, or dcAdminInvestigation" // Stores local flag values for xidburst list type xidBurstListFlags struct { @@ -30,6 +31,8 @@ type xidBurstListFlags struct { node string nodeGroupIDs string computeZoneIDs string + excludeNodeGroupIDs string + excludeComputeZoneIDs string jobDisruption bool platformDisruption bool xidNumbers string @@ -107,6 +110,10 @@ callers; for tenant callers the backend rejects them.`, cmd.Flags().StringVar(&flags.node, "node", "", "Filter by node UUID") cmd.Flags().StringVar(&flags.nodeGroupIDs, "nodegroup-ids", "", "Comma-separated node group IDs to filter") cmd.Flags().StringVar(&flags.computeZoneIDs, "compute-zone-ids", "", "Comma-separated compute zone IDs to filter") + cmd.Flags().StringVar(&flags.excludeNodeGroupIDs, "exclude-nodegroup-ids", "", + "Comma-separated node group IDs to exclude; cannot be combined with --nodegroup-ids") + cmd.Flags().StringVar(&flags.excludeComputeZoneIDs, "exclude-compute-zone-ids", "", + "Comma-separated compute zone IDs to exclude; cannot be combined with --compute-zone-ids") cmd.Flags().BoolVar(&flags.jobDisruption, "job-disruption", false, "Filter by the public fatal-XID job-disruption value") cmd.Flags().BoolVar(&flags.platformDisruption, "platform-disruption", false, "Filter by analyzer platform-attributed disruption (cloud-provider/NCP only)") cmd.Flags().StringVar(&flags.xidNumbers, "xid-numbers", "", "Comma-separated XID numbers; a burst matches if it contains any of them") @@ -200,6 +207,8 @@ func runXIDBurstList(cmd *cobra.Command, flags xidBurstListFlags, common resolve }{ {name: "nodegroup-ids", raw: flags.nodeGroupIDs, assign: func(v []string) { opts.NodeGroupIDs = v }}, {name: "compute-zone-ids", raw: flags.computeZoneIDs, assign: func(v []string) { opts.ComputeZoneIDs = v }}, + {name: "exclude-nodegroup-ids", raw: flags.excludeNodeGroupIDs, assign: func(v []string) { opts.ExcludeNodeGroupIDs = v }}, + {name: "exclude-compute-zone-ids", raw: flags.excludeComputeZoneIDs, assign: func(v []string) { opts.ExcludeComputeZoneIDs = v }}, {name: "categories", raw: flags.categories, assign: func(v []string) { opts.Categories = v }}, {name: "subcategories", raw: flags.subcategories, assign: func(v []string) { opts.Subcategories = v }}, {name: "tenant-actions", raw: flags.tenantActions, assign: func(v []string) { opts.TenantActions = v }}, @@ -214,6 +223,13 @@ func runXIDBurstList(cmd *cobra.Command, flags xidBurstListFlags, common resolve filter.assign(values) } + if len(opts.NodeGroupIDs) > 0 && len(opts.ExcludeNodeGroupIDs) > 0 { + return errors.New("--nodegroup-ids cannot be used with --exclude-nodegroup-ids") + } + if len(opts.ComputeZoneIDs) > 0 && len(opts.ExcludeComputeZoneIDs) > 0 { + return errors.New("--compute-zone-ids cannot be used with --exclude-compute-zone-ids") + } + client, err := newConfiguredClient(common) if err != nil { return err diff --git a/cmd/nvfleetint/xid_test.go b/cmd/nvfleetint/xid_test.go index 4634305..7c5fc6f 100644 --- a/cmd/nvfleetint/xid_test.go +++ b/cmd/nvfleetint/xid_test.go @@ -7,6 +7,8 @@ import ( "bytes" "net/http" "net/http/httptest" + "net/url" + "reflect" "strings" "testing" @@ -110,6 +112,44 @@ func TestXIDBurstListJSON(t *testing.T) { } } +// Verifies the exclusion filter flags reach the backend as query parameters +func TestXIDBurstListExclusionFlags(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + var query url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(xidBurstsBody)) + })) + defer server.Close() + + saveTestConfig(t, server.URL, "test-key") + + cmd := newRootCmd() + cmd.SetOut(new(bytes.Buffer)) + cmd.SetArgs([]string{ + "xidburst", "list", "--window", "24h", + "--exclude-nodegroup-ids", "ng-1,ng-2", + "--exclude-compute-zone-ids", "cz-1", + "--sort-by", "xidCount", + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("command failed: %v", err) + } + + if got := query["excludeNodeGroupIds"]; !reflect.DeepEqual(got, []string{"ng-1", "ng-2"}) { + t.Fatalf("unexpected excludeNodeGroupIds: %#v", got) + } + if got := query["excludeComputeZoneIds"]; !reflect.DeepEqual(got, []string{"cz-1"}) { + t.Fatalf("unexpected excludeComputeZoneIds: %#v", got) + } + if got := query.Get("sortBy"); got != "xidCount" { + t.Fatalf("unexpected sortBy: %q", got) + } +} + // Verifies xidburst list rejects invalid flags before any request func TestXIDBurstListFlagValidation(t *testing.T) { t.Setenv("HOME", t.TempDir()) @@ -131,6 +171,14 @@ func TestXIDBurstListFlagValidation(t *testing.T) { {"bad order", []string{"xidburst", "list", "--window", "24h", "--order", "sideways"}}, {"non-numeric xid", []string{"xidburst", "list", "--window", "24h", "--xid-numbers", "forty-eight"}}, {"bad output", []string{"xidburst", "list", "--window", "24h", "--output", "yaml"}}, + {"nodegroup include with exclude", []string{ + "xidburst", "list", "--window", "24h", + "--nodegroup-ids", "ng-1", "--exclude-nodegroup-ids", "ng-2", + }}, + {"compute zone include with exclude", []string{ + "xidburst", "list", "--window", "24h", + "--compute-zone-ids", "cz-1", "--exclude-compute-zone-ids", "cz-2", + }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/docs/cli.md b/docs/cli.md index e7173b7..f12e58d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -129,6 +129,7 @@ nvfleetint event buckets --window 24h # XID bursts nvfleetint xidburst list --window 24h nvfleetint xidburst list --window 168h --xid-numbers 48,94 --job-disruption +nvfleetint xidburst list --window 24h --exclude-nodegroup-ids ng-1,ng-2 nvfleetint xidburst describe # Tags and reports @@ -158,8 +159,11 @@ active view; pass `--view historical` for history. which applies to each burst's start time. Some of its filters and response fields — category, subcategory, platform-attributed disruption, XID descriptions, and DC-admin actions — are available only to cloud-provider/NCP -callers. `xidburst describe` adds the impacted GPU device IDs, per-XID catalog -details, and suggested actions. +callers. Node groups and compute zones can be filtered inclusively +(`--nodegroup-ids`, `--compute-zone-ids`) or by exclusion +(`--exclude-nodegroup-ids`, `--exclude-compute-zone-ids`), but not both for the +same dimension. `xidburst describe` adds the impacted GPU device IDs, per-XID +catalog details, and suggested actions. ## CSV reports diff --git a/nvfleetint/alert.go b/nvfleetint/alert.go index 52da906..99daf6e 100644 --- a/nvfleetint/alert.go +++ b/nvfleetint/alert.go @@ -35,7 +35,6 @@ const ( AlertTimelineNodeSortByComputeZone AlertTimelineNodeSortBy = "computeZone" AlertTimelineNodeSortByLastUpdate AlertTimelineNodeSortBy = "lastUpdate" - AlertTimelineAlertSortByComponent AlertTimelineAlertSortBy = "component" AlertTimelineAlertSortByStartTime AlertTimelineAlertSortBy = "startTime" AlertTimelineAlertSortByLastUpdate AlertTimelineAlertSortBy = "lastUpdate" diff --git a/nvfleetint/alert_test.go b/nvfleetint/alert_test.go index cd845f0..42950a0 100644 --- a/nvfleetint/alert_test.go +++ b/nvfleetint/alert_test.go @@ -265,6 +265,10 @@ func TestAlertTimelineRejectsInvalidOptions(t *testing.T) { if _, err := client.ListNodeAlertTimeline(context.Background(), ListNodeAlertTimelineOptions{NodeUUID: "node-1", AlertStates: []AlertTimelineState{"Triggered"}}); err == nil || !strings.Contains(err.Error(), "invalid alert timeline state") { t.Fatalf("unexpected timeline state error: %v", err) } + // The API dropped the component sort; only startTime and lastUpdate remain. + if _, err := client.ListNodeAlertTimeline(context.Background(), ListNodeAlertTimelineOptions{NodeUUID: "node-1", SortBy: "component"}); err == nil || !strings.Contains(err.Error(), "invalid node alert timeline sort") { + t.Fatalf("unexpected node alert sort error: %v", err) + } page := 1 if _, err := client.DescribeAlertTimelineWithOptions(context.Background(), "node-1", "alert-1", DescribeAlertTimelineOptions{Page: &page}); err == nil || !strings.Contains(err.Error(), "page requires page size") { t.Fatalf("unexpected detail pagination error: %v", err) diff --git a/nvfleetint/xid.go b/nvfleetint/xid.go index 2114cce..a44a3b7 100644 --- a/nvfleetint/xid.go +++ b/nvfleetint/xid.go @@ -6,6 +6,7 @@ package nvfleetint import ( "context" "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -19,14 +20,19 @@ const ( XIDBurstSortByCategory XIDBurstSortBy = "category" XIDBurstSortBySubcategory XIDBurstSortBy = "subcategory" XIDBurstSortByXIDNumbers XIDBurstSortBy = "xidNumbers" - XIDBurstSortByHostname XIDBurstSortBy = "hostname" - XIDBurstSortByNodeGroup XIDBurstSortBy = "nodeGroup" - XIDBurstSortByComputeZone XIDBurstSortBy = "computeZone" - XIDBurstSortByStartTime XIDBurstSortBy = "startTime" - XIDBurstSortByDCAdminAction XIDBurstSortBy = "dcAdminAction" - XIDBurstSortByDCAdminInvestigation XIDBurstSortBy = "dcAdminInvestigation" - XIDBurstSortByTenantAction XIDBurstSortBy = "tenantAction" - XIDBurstSortByTenantInvestigation XIDBurstSortBy = "tenantInvestigation" + XIDBurstSortByXIDCount XIDBurstSortBy = "xidCount" + XIDBurstSortByBurstDuration XIDBurstSortBy = "burstDurationSeconds" + // XIDBurstSortByNodeUUID spells the value "nodeUuid" to match the API contract, + // which differs from the nodeUUID casing used elsewhere in the spec. + XIDBurstSortByNodeUUID XIDBurstSortBy = "nodeUuid" + XIDBurstSortByHostname XIDBurstSortBy = "hostname" + XIDBurstSortByNodeGroup XIDBurstSortBy = "nodeGroup" + XIDBurstSortByComputeZone XIDBurstSortBy = "computeZone" + XIDBurstSortByStartTime XIDBurstSortBy = "startTime" + XIDBurstSortByDCAdminAction XIDBurstSortBy = "dcAdminAction" + XIDBurstSortByDCAdminInvestigation XIDBurstSortBy = "dcAdminInvestigation" + XIDBurstSortByTenantAction XIDBurstSortBy = "tenantAction" + XIDBurstSortByTenantInvestigation XIDBurstSortBy = "tenantInvestigation" XIDBurstOrderAsc XIDBurstSortOrder = "asc" XIDBurstOrderDesc XIDBurstSortOrder = "desc" @@ -61,6 +67,11 @@ type ListXIDBurstsOptions struct { NodeGroupIDs []string ComputeZoneIDs []string + // Exclusion filters select every assignment except the supplied IDs. Each + // cannot be combined with the inclusive filter for the same dimension. + ExcludeNodeGroupIDs []string + ExcludeComputeZoneIDs []string + JobDisruption *bool JobDisruptionDueToPlatformIssue *bool XIDNumbers []int @@ -165,6 +176,8 @@ func (c *Client) ListXIDBursts(ctx context.Context, opts ListXIDBurstsOptions) ( } params.NodeGroupIds = optionalStringSlice(opts.NodeGroupIDs) params.ComputeZoneIds = optionalStringSlice(opts.ComputeZoneIDs) + params.ExcludeNodeGroupIds = optionalStringSlice(opts.ExcludeNodeGroupIDs) + params.ExcludeComputeZoneIds = optionalStringSlice(opts.ExcludeComputeZoneIDs) params.JobDisruption = cloneBool(opts.JobDisruption) params.JobDisruptionDueToPlatformIssue = cloneBool(opts.JobDisruptionDueToPlatformIssue) if len(opts.XIDNumbers) > 0 { @@ -264,6 +277,12 @@ func validateListXIDBurstsOptions(opts ListXIDBurstsOptions) error { if opts.SortOrder != "" && !opts.SortOrder.Valid() { return fmt.Errorf("invalid XID burst sort order %q: expected asc or desc", opts.SortOrder) } + if len(opts.NodeGroupIDs) > 0 && len(opts.ExcludeNodeGroupIDs) > 0 { + return errors.New("node group include and exclude filters cannot be combined") + } + if len(opts.ComputeZoneIDs) > 0 && len(opts.ExcludeComputeZoneIDs) > 0 { + return errors.New("compute zone include and exclude filters cannot be combined") + } for _, xid := range opts.XIDNumbers { if xid < 0 { return fmt.Errorf("invalid XID number %d: expected a non-negative integer", xid) diff --git a/nvfleetint/xid_test.go b/nvfleetint/xid_test.go index 38b32cd..80f1fbe 100644 --- a/nvfleetint/xid_test.go +++ b/nvfleetint/xid_test.go @@ -170,6 +170,91 @@ func TestListXIDBurstsAbsoluteRange(t *testing.T) { } } +// Verifies exclusion filters are sent and omitted when unset +func TestListXIDBurstsSendsExclusionFilters(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + if got := query["excludeNodeGroupIds"]; !reflect.DeepEqual(got, []string{"ng-1", "ng-2"}) { + t.Fatalf("unexpected excludeNodeGroupIds: %#v", got) + } + if got := query["excludeComputeZoneIds"]; !reflect.DeepEqual(got, []string{"cz-1"}) { + t.Fatalf("unexpected excludeComputeZoneIds: %#v", got) + } + // The inclusive filters were left unset and must not be sent alongside. + for _, key := range []string{"nodeGroupIds", "computeZoneIds"} { + if query.Has(key) { + t.Fatalf("did not expect %s: %q", key, r.URL.RawQuery) + } + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[],"page":0,"pageSize":20,"total":0}`)) + })) + defer server.Close() + + client, err := NewClient(server.URL, "test-key") + if err != nil { + t.Fatalf("new client failed: %v", err) + } + + if _, err := client.ListXIDBursts(context.Background(), ListXIDBurstsOptions{ + Window: "24h", + ExcludeNodeGroupIDs: []string{"ng-1", "ng-2"}, + ExcludeComputeZoneIDs: []string{"cz-1"}, + }); err != nil { + t.Fatalf("list XID bursts failed: %v", err) + } +} + +// Verifies every exported sort field is accepted by the API contract and sent verbatim +func TestListXIDBurstsSortFields(t *testing.T) { + sortFields := []XIDBurstSortBy{ + XIDBurstSortByJobDisruption, + XIDBurstSortByJobDisruptionDueToPlatformIssue, + XIDBurstSortByCategory, + XIDBurstSortBySubcategory, + XIDBurstSortByXIDNumbers, + XIDBurstSortByXIDCount, + XIDBurstSortByBurstDuration, + XIDBurstSortByNodeUUID, + XIDBurstSortByHostname, + XIDBurstSortByNodeGroup, + XIDBurstSortByComputeZone, + XIDBurstSortByStartTime, + XIDBurstSortByDCAdminAction, + XIDBurstSortByDCAdminInvestigation, + XIDBurstSortByTenantAction, + XIDBurstSortByTenantInvestigation, + } + + for _, sortBy := range sortFields { + t.Run(string(sortBy), func(t *testing.T) { + if !sortBy.Valid() { + t.Fatalf("sort field %q is not accepted by the API contract", sortBy) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("sortBy"); got != string(sortBy) { + t.Fatalf("unexpected sortBy: %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[],"page":0,"pageSize":20,"total":0}`)) + })) + defer server.Close() + + client, err := NewClient(server.URL, "test-key") + if err != nil { + t.Fatalf("new client failed: %v", err) + } + if _, err := client.ListXIDBursts(context.Background(), ListXIDBurstsOptions{ + Window: "24h", + SortBy: sortBy, + }); err != nil { + t.Fatalf("list XID bursts failed: %v", err) + } + }) + } +} + // Verifies invalid list options are rejected before any request func TestListXIDBurstsValidation(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { @@ -193,6 +278,16 @@ func TestListXIDBurstsValidation(t *testing.T) { {"bad sort", ListXIDBurstsOptions{Window: "24h", SortBy: "burstId"}, "invalid XID burst sort"}, {"bad order", ListXIDBurstsOptions{Window: "24h", SortOrder: "sideways"}, "invalid XID burst sort order"}, {"negative XID", ListXIDBurstsOptions{Window: "24h", XIDNumbers: []int{-1}}, "invalid XID number"}, + { + "node group include with exclude", + ListXIDBurstsOptions{Window: "24h", NodeGroupIDs: []string{"ng-1"}, ExcludeNodeGroupIDs: []string{"ng-2"}}, + "node group include and exclude filters cannot be combined", + }, + { + "compute zone include with exclude", + ListXIDBurstsOptions{Window: "24h", ComputeZoneIDs: []string{"cz-1"}, ExcludeComputeZoneIDs: []string{"cz-2"}}, + "compute zone include and exclude filters cannot be combined", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From cf24f35774621cc318a2387fa85a5bb4d2eb9a0d Mon Sep 17 00:00:00 2001 From: Emily Zhang Date: Mon, 17 Aug 2026 14:46:15 -0700 Subject: [PATCH 4/4] chore: add copyright header to openapi spec Signed-off-by: Emily Zhang --- api/openapi/openapi.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml index d74f65d..d96981c 100644 --- a/api/openapi/openapi.yaml +++ b/api/openapi/openapi.yaml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + info: title: Fleet Intelligence API (external) version: ''