diff --git a/cmd/nvfleetint/computezone.go b/cmd/nvfleetint/computezone.go index 56a82c7..4d746fc 100644 --- a/cmd/nvfleetint/computezone.go +++ b/cmd/nvfleetint/computezone.go @@ -4,9 +4,12 @@ package main import ( + "bytes" + "encoding/json" "errors" "fmt" "io" + "strings" "github.com/NVIDIA/fleet-intelligence-client/internal/clihelpers" clioutput "github.com/NVIDIA/fleet-intelligence-client/internal/output" @@ -22,6 +25,20 @@ type computeZoneListFlags struct { zoneIDs string } +// Stores local flag values for computezone update +type computeZoneUpdateFlags struct { + zoneType string + contactEmail string + contactPIC string + geoCity string + geoCountry string + geoRegion string + geoLatitude string + geoLongitude string + yes bool + dryRun bool +} + // Stores data ready for computezone list rendering type computeZoneListOutput struct { ComputeZones []nvfleetint.ComputeZone @@ -40,6 +57,7 @@ func newComputeZoneCmd() *cobra.Command { } cmd.AddCommand(newComputeZoneListCmd()) + cmd.AddCommand(newComputeZoneUpdateCmd()) rejectUnknownSubcommands(cmd) return cmd @@ -70,6 +88,35 @@ func newComputeZoneListCmd() *cobra.Command { return cmd } +// Creates the compute zone update command +func newComputeZoneUpdateCmd() *cobra.Command { + flags := computeZoneUpdateFlags{} + common := newCommonFlags() + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update compute zone metadata", + Args: requireSingleArg("compute zone ID"), + RunE: func(cmd *cobra.Command, args []string) error { + return runComputeZoneUpdate(cmd, args[0], flags, resolveCommonFlags(cmd, common)) + }, + } + + cmd.Flags().StringVar(&flags.zoneType, "type", "", `Compute zone type: datacenter or "cloud provider"`) + cmd.Flags().StringVar(&flags.contactEmail, "contact-email", "", "Contact email; pass an empty value to clear") + cmd.Flags().StringVar(&flags.contactPIC, "contact-pic", "", "Contact person in charge; pass an empty value to clear") + cmd.Flags().StringVar(&flags.geoCity, "geo-city", "", "Location city; pass an empty value to clear") + cmd.Flags().StringVar(&flags.geoCountry, "geo-country", "", "Location country; pass an empty value to clear") + cmd.Flags().StringVar(&flags.geoRegion, "geo-region", "", "Location region; pass an empty value to clear") + cmd.Flags().StringVar(&flags.geoLatitude, "geo-latitude", "", "Location latitude between -90 and 90; pass an empty value to clear") + cmd.Flags().StringVar(&flags.geoLongitude, "geo-longitude", "", "Location longitude between -180 and 180; pass an empty value to clear") + cmd.Flags().BoolVar(&flags.yes, "yes", false, "Skip the confirmation prompt") + cmd.Flags().BoolVar(&flags.dryRun, "dry-run", false, "Preview the request without sending it") + registerReadCommonFlags(cmd, common) + + return cmd +} + // Validates flags, calls the SDK, and writes output func runComputeZoneList(cmd *cobra.Command, flags computeZoneListFlags, common resolvedCommonFlags) error { if err := validateComputeZoneListFlags(flags, common); err != nil { @@ -143,6 +190,39 @@ func runComputeZoneList(cmd *cobra.Command, flags computeZoneListFlags, common r }) } +// Validates flags, calls the SDK, and writes output +func runComputeZoneUpdate(cmd *cobra.Command, id string, flags computeZoneUpdateFlags, common resolvedCommonFlags) error { + if err := validateComputeZoneUpdateFlags(cmd, flags, common); err != nil { + return err + } + + client, err := newConfiguredClient(common) + if err != nil { + return err + } + opts := computeZoneUpdateOptionsFromFlags(cmd, id, flags) + + if flags.dryRun { + preview, err := client.PreviewUpdateComputeZone(cmd.Context(), opts) + if err != nil { + return err + } + return writeComputeZoneUpdatePreview(cmd.OutOrStdout(), common, preview) + } + + if !flags.yes { + if err := clihelpers.Confirm(cmd.InOrStdin(), cmd.ErrOrStderr(), computeZoneUpdateSummary(id, opts)); err != nil { + return err + } + } + + result, err := client.UpdateComputeZone(cmd.Context(), opts) + if err != nil { + return err + } + return writeComputeZoneUpdateOutput(cmd.OutOrStdout(), common, result) +} + // Checks compute zone list flags func validateComputeZoneListFlags(flags computeZoneListFlags, common resolvedCommonFlags) error { if err := validateListCommonFlags(common); err != nil { @@ -154,6 +234,92 @@ func validateComputeZoneListFlags(flags computeZoneListFlags, common resolvedCom return nil } +// Checks compute zone update flags +func validateComputeZoneUpdateFlags(cmd *cobra.Command, flags computeZoneUpdateFlags, common resolvedCommonFlags) error { + if err := validateReadCommonFlags(common); err != nil { + return err + } + if !hasComputeZoneUpdateFlag(cmd) { + return errors.New("at least one update flag must be set") + } + if cmd.Flags().Changed("type") { + zoneType := strings.TrimSpace(flags.zoneType) + if zoneType == "" { + return errors.New("--type cannot be empty") + } + if !nvfleetint.ComputeZoneType(zoneType).Valid() { + return fmt.Errorf("invalid --type %q: expected datacenter or cloud provider", zoneType) + } + } + // An empty coordinate clears the stored value, so only real values are checked. + if cmd.Flags().Changed("geo-latitude") && strings.TrimSpace(flags.geoLatitude) != "" { + if err := nvfleetint.ValidateLatitude(flags.geoLatitude); err != nil { + return fmt.Errorf("--geo-latitude: %w", err) + } + } + if cmd.Flags().Changed("geo-longitude") && strings.TrimSpace(flags.geoLongitude) != "" { + if err := nvfleetint.ValidateLongitude(flags.geoLongitude); err != nil { + return fmt.Errorf("--geo-longitude: %w", err) + } + } + return nil +} + +func hasComputeZoneUpdateFlag(cmd *cobra.Command) bool { + for _, name := range []string{ + "type", + "contact-email", + "contact-pic", + "geo-city", + "geo-country", + "geo-region", + "geo-latitude", + "geo-longitude", + } { + if cmd.Flags().Changed(name) { + return true + } + } + return false +} + +func computeZoneUpdateOptionsFromFlags(cmd *cobra.Command, id string, flags computeZoneUpdateFlags) nvfleetint.UpdateComputeZoneOptions { + opts := nvfleetint.UpdateComputeZoneOptions{ID: id} + if cmd.Flags().Changed("type") { + value := strings.TrimSpace(flags.zoneType) + opts.Type = &value + } + if cmd.Flags().Changed("contact-email") { + value := strings.TrimSpace(flags.contactEmail) + opts.ContactEmail = &value + } + if cmd.Flags().Changed("contact-pic") { + value := strings.TrimSpace(flags.contactPIC) + opts.ContactPIC = &value + } + if cmd.Flags().Changed("geo-city") { + value := strings.TrimSpace(flags.geoCity) + opts.GeoCity = &value + } + if cmd.Flags().Changed("geo-country") { + value := strings.TrimSpace(flags.geoCountry) + opts.GeoCountry = &value + } + if cmd.Flags().Changed("geo-region") { + value := strings.TrimSpace(flags.geoRegion) + opts.GeoRegion = &value + } + if cmd.Flags().Changed("geo-latitude") { + value := strings.TrimSpace(flags.geoLatitude) + opts.GeoLatitude = &value + } + if cmd.Flags().Changed("geo-longitude") { + value := strings.TrimSpace(flags.geoLongitude) + opts.GeoLongitude = &value + } + return opts +} + // Writes JSON or table output for compute zone list results func writeComputeZoneListOutput(w io.Writer, common resolvedCommonFlags, result computeZoneListOutput) error { if common.output == clioutput.FormatJSON { @@ -169,6 +335,65 @@ func writeComputeZoneListOutput(w io.Writer, common resolvedCommonFlags, result return clioutput.WritePaginationFooter(w, *result.Page) } +// Writes JSON or text output for a successful compute zone update +func writeComputeZoneUpdateOutput(w io.Writer, common resolvedCommonFlags, result nvfleetint.UpdateComputeZoneResult) error { + if common.output == clioutput.FormatJSON { + return clioutput.WriteRawJSON(w, result.RawJSON) + } + + id := clioutput.DisplayString(result.ID) + _, err := fmt.Fprintf(w, "Compute zone %q updated.\n", id) + return err +} + +// Writes the dry-run request preview +func writeComputeZoneUpdatePreview(w io.Writer, common resolvedCommonFlags, preview nvfleetint.RequestPreview) error { + if common.output == clioutput.FormatJSON { + return clioutput.WriteJSON(w, preview) + } + + prettyBody := preview.Body + var formatted bytes.Buffer + if len(preview.Body) > 0 && json.Indent(&formatted, preview.Body, "", " ") == nil { + prettyBody = formatted.Bytes() + } + + if _, err := fmt.Fprintf(w, "Dry run: no write request sent.\nMETHOD: %s\nURL: %s\nBODY:\n%s\n", preview.Method, preview.URL, prettyBody); err != nil { + return err + } + return nil +} + +func computeZoneUpdateSummary(id string, opts nvfleetint.UpdateComputeZoneOptions) string { + var fields []string + if opts.Type != nil { + fields = append(fields, "type") + } + if opts.ContactEmail != nil { + fields = append(fields, "contact email") + } + if opts.ContactPIC != nil { + fields = append(fields, "contact PIC") + } + if opts.GeoCity != nil { + fields = append(fields, "geo city") + } + if opts.GeoCountry != nil { + fields = append(fields, "geo country") + } + if opts.GeoRegion != nil { + fields = append(fields, "geo region") + } + if opts.GeoLatitude != nil { + fields = append(fields, "geo latitude") + } + if opts.GeoLongitude != nil { + fields = append(fields, "geo longitude") + } + + return fmt.Sprintf("Update compute zone %q fields: %s.", id, strings.Join(fields, ", ")) +} + // Renders compute zones using the selected view columns func writeComputeZoneTable(w io.Writer, view string, zones []nvfleetint.ComputeZone) error { if nvfleetint.ComputeZoneView(view) == nvfleetint.ComputeZoneViewBasic { diff --git a/cmd/nvfleetint/computezone_test.go b/cmd/nvfleetint/computezone_test.go index 5cd2091..3d91fa3 100644 --- a/cmd/nvfleetint/computezone_test.go +++ b/cmd/nvfleetint/computezone_test.go @@ -6,6 +6,7 @@ package main import ( "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" "slices" @@ -151,6 +152,156 @@ func TestWriteComputeZoneBasicTable(t *testing.T) { } } +// Verifies update reads current state before writing a merged body +func TestComputeZoneUpdatePreservesUnchangedBackendFields(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + var sawPut bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + if r.URL.Path != "/v1/computezones" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query()["computeZoneIds"]; !slices.Equal(got, []string{"cz-1"}) { + t.Fatalf("unexpected computeZoneIds: %#v", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"computezones":[{"id":"cz-1","type":"datacenter","contact":{"email":"old@example.com","pic":"Grace"},"geoLocation":{"city":"Santa Clara","country":"US","region":"us-west"}}],"hasMore":false,"page":0,"pageSize":20,"total":1}`)) + case http.MethodPut: + sawPut = true + data, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body failed: %v", err) + } + body := string(data) + for _, want := range []string{`"id":"cz-1"`, `"type":"datacenter"`, `"email":"new@example.com"`, `"pic":"Grace"`, `"city":"Santa Clara"`, `"country":"CA"`, `"region":"us-west"`} { + if !strings.Contains(body, want) { + t.Fatalf("body missing %q: %s", want, body) + } + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"cz-1"}`)) + default: + t.Fatalf("unexpected method: %s", r.Method) + } + })) + defer server.Close() + + saveTestConfig(t, server.URL, "test-key") + + stdout, stderr := runCLI(t, "computezone", "update", "cz-1", "--contact-email", "new@example.com", "--geo-country", "CA", "--yes") + if !sawPut { + t.Fatal("expected PUT request") + } + if !strings.Contains(stdout, `Compute zone "cz-1" updated.`) { + t.Fatalf("unexpected stdout: %q", stdout) + } + if stderr != "" { + t.Fatalf("unexpected stderr: %q", stderr) + } +} + +// Verifies dry-run reads current state and prints the merged request without writing +func TestComputeZoneUpdateDryRunJSON(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("dry-run should not write, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"computezones":[{"id":"cz-1","type":"datacenter","contact":{"email":"old@example.com","pic":"Grace"}}],"hasMore":false,"page":0,"pageSize":20,"total":1}`)) + })) + defer server.Close() + + saveTestConfig(t, server.URL, "test-key") + + stdout, stderr := runCLI(t, "computezone", "update", "cz-1", "--contact-email", "new@example.com", "--dry-run", "-o", "json") + if stderr != "" { + t.Fatalf("unexpected stderr: %q", stderr) + } + + var got nvfleetint.RequestPreview + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("decode preview failed: %v\n%s", err, stdout) + } + if got.Method != http.MethodPut || got.URL != server.URL+"/v1/computezones" { + t.Fatalf("unexpected preview: %#v", got) + } + if !strings.Contains(string(got.Body), `"email":"new@example.com"`) || !strings.Contains(string(got.Body), `"pic":"Grace"`) { + t.Fatalf("preview body did not merge fields: %s", string(got.Body)) + } +} + +// Verifies coordinate flags are validated before any request is made +func TestComputeZoneUpdateRejectsInvalidCoordinates(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + t.Fatalf("invalid coordinates should not reach the backend: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + saveTestConfig(t, server.URL, "test-key") + + tests := []struct { + name string + args []string + want string + }{ + {name: "latitude range", args: []string{"--geo-latitude", "1000"}, want: `--geo-latitude: invalid latitude "1000": must be between -90 and 90`}, + {name: "longitude range", args: []string{"--geo-longitude", "-400"}, want: `--geo-longitude: invalid longitude "-400": must be between -180 and 180`}, + {name: "latitude text", args: []string{"--geo-latitude", "north"}, want: "expected a decimal number"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := newRootCmd() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs(append([]string{"computezone", "update", "cz-1", "--yes"}, tt.args...)) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("unexpected error: got %v want %q", err, tt.want) + } + }) + } +} + +// Verifies an empty coordinate clears the stored value +func TestComputeZoneUpdateClearsCoordinates(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + var body string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"computezones":[{"id":"cz-1","geoLocation":{"city":"Santa Clara","latitude":37.4,"longitude":-121.9}}],"hasMore":false,"page":0,"pageSize":20,"total":1}`)) + return + } + data, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body failed: %v", err) + } + body = string(data) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"cz-1"}`)) + })) + defer server.Close() + + saveTestConfig(t, server.URL, "test-key") + + runCLI(t, "computezone", "update", "cz-1", "--geo-latitude", "", "--geo-longitude", "", "--yes") + if !strings.Contains(body, `"geoLocation":{"city":"Santa Clara"}`) { + t.Fatalf("coordinates were not cleared: %s", body) + } +} + // Verifies local flag validation func TestComputeZoneListRejectsInvalidFlags(t *testing.T) { tests := []struct { diff --git a/docs/cli.md b/docs/cli.md index f12e58d..abe6e48 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -109,6 +109,7 @@ prints the resulting current profile either way. # Fleet summary and inventory nvfleetint overview nvfleetint computezone list +nvfleetint computezone update --contact-email ops@example.com nvfleetint nodegroup list nvfleetint node list nvfleetint node list --agent-type oob --bmc-hostname bmc-01 @@ -141,6 +142,19 @@ nvfleetint report error --window 24h List commands support shared flags including `--all`, `--page`, `--page-size`, `--timeout`, and `--output json`. +`computezone update` changes compute-zone metadata: `--type`, +`--contact-email`, `--contact-pic`, `--geo-city`, `--geo-country`, +`--geo-region`, `--geo-latitude`, and `--geo-longitude`. It reads the current +compute zone first, keeps any fields whose flags were not provided, then sends +the merged update. Passing an empty value to a contact or geo flag clears that +field. Coordinates are validated as text (`--geo-latitude` between -90 and 90, +`--geo-longitude` between -180 and 180) so an untouched location is echoed back +to the backend exactly as stored. The command asks for confirmation unless +`--yes` is passed; use `--dry-run -o json` to inspect the request body without +sending the write. Because the API has no conditional-update mechanism, this +read-modify-write flow is last-write-wins: an update, including one sent after +a dry-run preview, can overwrite concurrent changes made after the read. + Detailed node list and describe commands query both in-band and out-of-band (OOB) views by default. Table output labels the two sections; JSON output uses top-level `inband` and `oob` objects. Use `--agent-type inband` or diff --git a/nvfleetint/common.go b/nvfleetint/common.go index f689927..3cd1957 100644 --- a/nvfleetint/common.go +++ b/nvfleetint/common.go @@ -24,6 +24,46 @@ type GeoLocation struct { Longitude *float32 `json:"longitude,omitempty"` } +// Reports whether value is a latitude the API accepts: a decimal number +// between -90 and 90. Coordinates are validated as text so a stored value can +// be echoed back to the backend verbatim. +func ValidateLatitude(value string) error { + return validateCoordinate("latitude", value, 90) +} + +// Reports whether value is a longitude the API accepts: a decimal number +// between -180 and 180. See ValidateLatitude on why coordinates stay text. +func ValidateLongitude(value string) error { + return validateCoordinate("longitude", value, 180) +} + +// Checks that value is a JSON number literal within the given symmetric range +func validateCoordinate(name, value string, limit float64) error { + trimmed := strings.TrimSpace(value) + invalid := fmt.Errorf("invalid %s %q: expected a decimal number", name, value) + if trimmed == "" { + return fmt.Errorf("%s cannot be empty", name) + } + // Reject anything that is not a bare JSON number before decoding, so + // quoted values and literals like null never reach the backend. + if first := trimmed[0]; first != '-' && (first < '0' || first > '9') { + return invalid + } + var number json.Number + if err := json.Unmarshal([]byte(trimmed), &number); err != nil { + return invalid + } + parsed, err := number.Float64() + if err != nil { + return invalid + } + if parsed < -limit || parsed > limit { + return fmt.Errorf("invalid %s %q: must be between %g and %g", name, value, -limit, limit) + } + + return nil +} + // Represents a non-success API response type APIError struct { StatusCode int @@ -135,6 +175,24 @@ func cloneInt(value *int) *int { return &out } +// Copies optional strings without sharing pointers +func cloneString(value *string) *string { + if value == nil { + return nil + } + out := *value + return &out +} + +// Copies optional JSON numbers without sharing pointers +func cloneJSONNumber(value *json.Number) *json.Number { + if value == nil { + return nil + } + out := *value + return &out +} + // Copies optional float values without sharing pointers func cloneFloat32(value *float32) *float32 { if value == nil { diff --git a/nvfleetint/computezone.go b/nvfleetint/computezone.go index 4701837..51e2d3b 100644 --- a/nvfleetint/computezone.go +++ b/nvfleetint/computezone.go @@ -4,10 +4,12 @@ package nvfleetint import ( + "bytes" "context" "encoding/json" "fmt" "net/http" + "strings" "github.com/NVIDIA/fleet-intelligence-client/internal/generated/fleetapi" ) @@ -17,14 +19,27 @@ const ( ComputeZoneViewBasic ComputeZoneView = "basic" ) +const ( + ComputeZoneTypeDatacenter ComputeZoneType = "datacenter" + ComputeZoneTypeCloudProvider ComputeZoneType = "cloud provider" +) + // Represents supported response shapes for listing compute zones type ComputeZoneView string +// Represents supported compute zone types +type ComputeZoneType string + // Reports whether the view is accepted by the API func (view ComputeZoneView) Valid() bool { return fleetapi.GetV1ComputezonesParamsView(view).Valid() } +// Reports whether the type is accepted by the API +func (zoneType ComputeZoneType) Valid() bool { + return fleetapi.ModelsComputeZoneType(zoneType).Valid() +} + // Represents request options for listing compute zones type ListComputeZonesOptions struct { View ComputeZoneView @@ -44,15 +59,44 @@ type ComputeZonesPage struct { RawJSON []byte `json:"-"` } +// Represents contact metadata for a compute zone +type Contact struct { + Email string `json:"email,omitempty"` + PIC string `json:"pic,omitempty"` +} + // Represents a compute zone type ComputeZone struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type,omitempty"` + Contact *Contact `json:"contact,omitempty"` GeoLocation *GeoLocation `json:"geoLocation,omitempty"` NodeCount *int `json:"nodeCount,omitempty"` } +// Represents request options for updating a compute zone. Pointer fields are +// values the caller wants to change; nil fields preserve the backend value. +// Coordinates are text so an untouched value round-trips verbatim and an empty +// value can clear one; validate them with ValidateLatitude/ValidateLongitude. +type UpdateComputeZoneOptions struct { + ID string + Type *string + ContactEmail *string + ContactPIC *string + GeoCity *string + GeoCountry *string + GeoRegion *string + GeoLatitude *string + GeoLongitude *string +} + +// Represents an update response with the raw backend payload +type UpdateComputeZoneResult struct { + ID string `json:"id"` + RawJSON []byte `json:"-"` +} + // Lists compute zones using the configured API client func (c *Client) ListComputeZones(ctx context.Context, opts ListComputeZonesOptions) (ComputeZonesPage, error) { ctx, cancel := c.requestContext(ctx) @@ -98,6 +142,41 @@ func (c *Client) ListComputeZones(ctx context.Context, opts ListComputeZonesOpti return decodeDetailComputeZones(resp.Body) } +// Updates a compute zone by first reading its current backend state and then +// preserving fields the caller left nil. The API has no conditional-update +// mechanism, so this read-modify-write flow is last-write-wins and can +// overwrite concurrent changes made after the read. +func (c *Client) UpdateComputeZone(ctx context.Context, opts UpdateComputeZoneOptions) (UpdateComputeZoneResult, error) { + body, err := c.buildUpdateComputeZoneRequest(ctx, opts) + if err != nil { + return UpdateComputeZoneResult{}, err + } + data, err := json.Marshal(body) + if err != nil { + return UpdateComputeZoneResult{}, err + } + + ctx, cancel := c.requestContext(ctx) + defer cancel() + + resp, err := c.api.PutV1ComputezonesWithBodyWithResponse(ctx, "application/json", bytes.NewReader(data)) + if err != nil { + return UpdateComputeZoneResult{}, err + } + if resp.StatusCode() != http.StatusOK { + return UpdateComputeZoneResult{}, newAPIError(resp.StatusCode(), resp.Status(), resp.Body) + } + + result := UpdateComputeZoneResult{ + RawJSON: append([]byte(nil), resp.Body...), + } + if resp.JSON200 != nil { + result.ID = stringValue(resp.JSON200.Id) + } + + return result, nil +} + // Defaults an omitted view and rejects unsupported values func normalizeComputeZoneView(view ComputeZoneView) (ComputeZoneView, error) { if view == "" { @@ -116,6 +195,217 @@ func computeZoneViewParam(view ComputeZoneView) *fleetapi.GetV1ComputezonesParam return ¶m } +// Represents the update request body. This mirrors +// fleetapi.ModelsUpdateComputeZoneRequest but carries coordinates as +// json.Number: the generated model decodes them as float32, so echoing an +// untouched location back through it would rewrite the stored value at +// float32 precision on every unrelated edit. +type updateComputeZoneBody struct { + Contact *computeZoneContactBody `json:"contact,omitempty"` + GeoLocation *computeZoneGeoLocationBody `json:"geoLocation,omitempty"` + ID string `json:"id"` + Type *string `json:"type,omitempty"` +} + +// Mirrors fleetapi.ModelsContact for the request body +type computeZoneContactBody struct { + Email *string `json:"email,omitempty"` + Pic *string `json:"pic,omitempty"` +} + +// Mirrors fleetapi.ModelsGeoLocation for the request body, keeping coordinates +// as the backend's own number text +type computeZoneGeoLocationBody struct { + City *string `json:"city,omitempty"` + Country *string `json:"country,omitempty"` + Latitude *json.Number `json:"latitude,omitempty"` + Longitude *json.Number `json:"longitude,omitempty"` + Region *string `json:"region,omitempty"` +} + +// Represents the stored compute zone fields the update merge reads back, +// decoded straight from the backend payload so coordinate text survives +type currentComputeZone struct { + ID string `json:"id"` + Type *string `json:"type"` + Contact *computeZoneContactBody `json:"contact"` + GeoLocation *computeZoneGeoLocationBody `json:"geoLocation"` +} + +// Represents the list envelope currentComputeZone is read from +type currentComputeZonesResponse struct { + ComputeZones []currentComputeZone `json:"computezones"` +} + +// Builds the request body shared by UpdateComputeZone and +// PreviewUpdateComputeZone so a dry run can never disagree with the write. +// The request body includes values read from the backend for fields the caller +// left nil; because the API has no ETag, version, or If-Match equivalent, +// preview-then-update and direct update flows are last-write-wins and can +// overwrite concurrent changes made after the read. +func (c *Client) buildUpdateComputeZoneRequest(ctx context.Context, opts UpdateComputeZoneOptions) (updateComputeZoneBody, error) { + opts.ID = strings.TrimSpace(opts.ID) + if err := validateUpdateComputeZoneOptions(opts); err != nil { + return updateComputeZoneBody{}, err + } + + current, err := c.currentComputeZone(ctx, opts.ID) + if err != nil { + return updateComputeZoneBody{}, err + } + + req := updateComputeZoneBody{ID: opts.ID, Type: cloneString(current.Type)} + if opts.Type != nil { + zoneType := strings.TrimSpace(*opts.Type) + req.Type = &zoneType + } + + contact := cloneComputeZoneContact(current.Contact) + if opts.ContactEmail != nil { + if contact == nil { + contact = &computeZoneContactBody{} + } + contact.Email = trimmedStringPointer(*opts.ContactEmail) + } + if opts.ContactPIC != nil { + if contact == nil { + contact = &computeZoneContactBody{} + } + contact.Pic = trimmedStringPointer(*opts.ContactPIC) + } + req.Contact = contact + + location := cloneComputeZoneGeoLocation(current.GeoLocation) + if opts.GeoCity != nil { + if location == nil { + location = &computeZoneGeoLocationBody{} + } + location.City = trimmedStringPointer(*opts.GeoCity) + } + if opts.GeoCountry != nil { + if location == nil { + location = &computeZoneGeoLocationBody{} + } + location.Country = trimmedStringPointer(*opts.GeoCountry) + } + if opts.GeoRegion != nil { + if location == nil { + location = &computeZoneGeoLocationBody{} + } + location.Region = trimmedStringPointer(*opts.GeoRegion) + } + if opts.GeoLatitude != nil { + if location == nil { + location = &computeZoneGeoLocationBody{} + } + location.Latitude = coordinateNumber(*opts.GeoLatitude) + } + if opts.GeoLongitude != nil { + if location == nil { + location = &computeZoneGeoLocationBody{} + } + location.Longitude = coordinateNumber(*opts.GeoLongitude) + } + req.GeoLocation = location + + return req, nil +} + +func validateUpdateComputeZoneOptions(opts UpdateComputeZoneOptions) error { + if strings.TrimSpace(opts.ID) == "" { + return fmt.Errorf("compute zone ID is required") + } + if len(opts.ID) > 255 { + return fmt.Errorf("compute zone ID must be at most 255 characters") + } + if opts.Type != nil { + zoneType := strings.TrimSpace(*opts.Type) + if zoneType == "" { + return fmt.Errorf("compute zone type cannot be empty") + } + if !ComputeZoneType(zoneType).Valid() { + return fmt.Errorf("invalid compute zone type %q: expected datacenter or cloud provider", zoneType) + } + } + // An empty coordinate clears the value, so only real values are checked. + if opts.GeoLatitude != nil && strings.TrimSpace(*opts.GeoLatitude) != "" { + if err := ValidateLatitude(*opts.GeoLatitude); err != nil { + return err + } + } + if opts.GeoLongitude != nil && strings.TrimSpace(*opts.GeoLongitude) != "" { + if err := ValidateLongitude(*opts.GeoLongitude); err != nil { + return err + } + } + return nil +} + +// Reads the stored compute zone the update merges over. There is no read-one +// endpoint, so the list is filtered to the requested ID. +func (c *Client) currentComputeZone(ctx context.Context, id string) (currentComputeZone, error) { + includeMetrics := false + page, err := c.ListComputeZones(ctx, ListComputeZonesOptions{ + View: ComputeZoneViewDetail, + IncludeMetrics: &includeMetrics, + ZoneIDs: []string{id}, + }) + if err != nil { + return currentComputeZone{}, err + } + + var resp currentComputeZonesResponse + if err := json.Unmarshal(page.RawJSON, &resp); err != nil { + return currentComputeZone{}, err + } + for _, zone := range resp.ComputeZones { + if zone.ID == id { + return zone, nil + } + } + + return currentComputeZone{}, fmt.Errorf("compute zone %q not found", id) +} + +func cloneComputeZoneContact(contact *computeZoneContactBody) *computeZoneContactBody { + if contact == nil { + return nil + } + return &computeZoneContactBody{ + Email: cloneString(contact.Email), + Pic: cloneString(contact.Pic), + } +} + +func cloneComputeZoneGeoLocation(location *computeZoneGeoLocationBody) *computeZoneGeoLocationBody { + if location == nil { + return nil + } + return &computeZoneGeoLocationBody{ + City: cloneString(location.City), + Country: cloneString(location.Country), + Latitude: cloneJSONNumber(location.Latitude), + Longitude: cloneJSONNumber(location.Longitude), + Region: cloneString(location.Region), + } +} + +// Converts a coordinate option into wire text. An empty value clears the +// coordinate by omitting it from the replacement document. +func coordinateNumber(value string) *json.Number { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + number := json.Number(trimmed) + return &number +} + +func trimmedStringPointer(value string) *string { + trimmed := strings.TrimSpace(value) + return &trimmed +} + // Decodes detail responses and preserves the original payload func decodeDetailComputeZones(data []byte) (ComputeZonesPage, error) { var resp fleetapi.ModelsComputeZonesResponse @@ -164,12 +454,23 @@ func decodeBasicComputeZones(data []byte) (ComputeZonesPage, error) { return page, nil } +func contactFromGenerated(contact *fleetapi.ModelsContact) *Contact { + if contact == nil { + return nil + } + return &Contact{ + Email: stringValue(contact.Email), + PIC: stringValue(contact.Pic), + } +} + // Maps detail API models into SDK values func computeZoneFromOverview(zone fleetapi.ModelsComputeZoneOverview) ComputeZone { return ComputeZone{ ID: stringValue(zone.Id), Name: stringValue(zone.Name), Type: enumStringValue(zone.Type), + Contact: contactFromGenerated(zone.Contact), GeoLocation: geoLocationFromGenerated(zone.GeoLocation), NodeCount: cloneInt(zone.NodesCount), } @@ -181,6 +482,7 @@ func computeZoneFromSimple(zone fleetapi.ModelsSimpleComputeZone) ComputeZone { ID: stringValue(zone.Id), Name: stringValue(zone.Name), Type: enumStringValue(zone.Type), + Contact: contactFromGenerated(zone.Contact), GeoLocation: geoLocationFromGenerated(zone.GeoLocation), } } diff --git a/nvfleetint/computezone_test.go b/nvfleetint/computezone_test.go index 149417f..9b55f16 100644 --- a/nvfleetint/computezone_test.go +++ b/nvfleetint/computezone_test.go @@ -5,6 +5,8 @@ package nvfleetint import ( "context" + "encoding/json" + "io" "net/http" "net/http/httptest" "slices" @@ -111,6 +113,232 @@ func TestListComputeZonesBasic(t *testing.T) { } } +// Verifies updates preserve backend fields that were not explicitly changed +func TestUpdateComputeZonePreservesBackendFields(t *testing.T) { + requests := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.Path) + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Fatalf("unexpected auth header: %q", got) + } + + switch r.Method { + case http.MethodGet: + if r.URL.Path != "/v1/computezones" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + query := r.URL.Query() + if got := query.Get("view"); got != "detail" { + t.Fatalf("unexpected view: %q", got) + } + if got := query.Get("includeMetrics"); got != "false" { + t.Fatalf("unexpected includeMetrics: %q", got) + } + if got := query["computeZoneIds"]; !slices.Equal(got, []string{"cz-1"}) { + t.Fatalf("unexpected computeZoneIds: %#v", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"computezones":[{"id":"cz-1","name":"East","type":"datacenter","contact":{"email":"old@example.com","pic":"Ada"},"geoLocation":{"city":"Santa Clara","country":"US","region":"us-west","latitude":37.774929,"longitude":-122.419416}}],"hasMore":false,"page":0,"pageSize":20,"total":1}`)) + case http.MethodPut: + if r.URL.Path != "/v1/computezones" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + var body struct { + ID string `json:"id"` + Type string `json:"type"` + Contact struct { + Email string `json:"email"` + PIC string `json:"pic"` + } `json:"contact"` + GeoLocation struct { + City string `json:"city"` + Country string `json:"country"` + Region string `json:"region"` + Latitude json.Number `json:"latitude"` + Longitude json.Number `json:"longitude"` + } `json:"geoLocation"` + } + data, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body failed: %v", err) + } + if err := json.Unmarshal(data, &body); err != nil { + t.Fatalf("decode body failed: %v\n%s", err, string(data)) + } + if body.ID != "cz-1" || body.Type != "datacenter" { + t.Fatalf("backend type or ID was not preserved: %#v", body) + } + if body.Contact.Email != "new@example.com" || body.Contact.PIC != "Ada" { + t.Fatalf("contact fields were not merged: %#v", body.Contact) + } + if body.GeoLocation.City != "Austin" || body.GeoLocation.Country != "US" || body.GeoLocation.Region != "us-west" { + t.Fatalf("geo fields were not merged: %#v", body.GeoLocation) + } + // Untouched coordinates must be echoed back byte-for-byte; decoding + // them through the generated float32 model would rewrite them. + if body.GeoLocation.Latitude.String() != "37.774929" || body.GeoLocation.Longitude.String() != "-122.419416" { + t.Fatalf("coordinates lost precision: %s / %s", body.GeoLocation.Latitude, body.GeoLocation.Longitude) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"cz-1"}`)) + default: + t.Fatalf("unexpected method: %s", r.Method) + } + })) + defer server.Close() + + client, err := NewClient(server.URL, "test-key") + if err != nil { + t.Fatalf("new client failed: %v", err) + } + + email := "new@example.com" + city := "Austin" + got, err := client.UpdateComputeZone(context.Background(), UpdateComputeZoneOptions{ + ID: "cz-1", + ContactEmail: &email, + GeoCity: &city, + }) + if err != nil { + t.Fatalf("update failed: %v", err) + } + if got.ID != "cz-1" || !strings.Contains(string(got.RawJSON), `"id":"cz-1"`) { + t.Fatalf("unexpected result: %#v raw %q", got, string(got.RawJSON)) + } + if !slices.Equal(requests, []string{"GET /v1/computezones", "PUT /v1/computezones"}) { + t.Fatalf("unexpected requests: %#v", requests) + } +} + +// Verifies coordinates are sent as the caller's own text and can be cleared +func TestUpdateComputeZoneCoordinates(t *testing.T) { + tests := []struct { + name string + latitude string + longitude string + want string + }{ + {name: "set", latitude: "37.774929", longitude: "-122.419416", want: `"latitude":37.774929,"longitude":-122.419416`}, + {name: "high precision", latitude: "37.7749295361", longitude: "-122.4194155008", want: `"latitude":37.7749295361,"longitude":-122.4194155008`}, + {name: "clear", latitude: "", longitude: "", want: `"geoLocation":{"city":"Santa Clara"}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var body string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"computezones":[{"id":"cz-1","geoLocation":{"city":"Santa Clara","latitude":1.5,"longitude":2.5}}],"hasMore":false,"page":0,"pageSize":20,"total":1}`)) + return + } + data, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body failed: %v", err) + } + body = string(data) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"cz-1"}`)) + })) + defer server.Close() + + client, err := NewClient(server.URL, "test-key") + if err != nil { + t.Fatalf("new client failed: %v", err) + } + + latitude := tt.latitude + longitude := tt.longitude + if _, err := client.UpdateComputeZone(context.Background(), UpdateComputeZoneOptions{ + ID: "cz-1", + GeoLatitude: &latitude, + GeoLongitude: &longitude, + }); err != nil { + t.Fatalf("update failed: %v", err) + } + if !strings.Contains(body, tt.want) { + t.Fatalf("body missing %q: %s", tt.want, body) + } + }) + } +} + +// Verifies out-of-range and non-numeric coordinates never reach the backend +func TestUpdateComputeZoneRejectsInvalidCoordinates(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + t.Fatalf("invalid coordinates should not reach the backend: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + client, err := NewClient(server.URL, "test-key") + if err != nil { + t.Fatalf("new client failed: %v", err) + } + + tests := []struct { + name string + value string + want string + }{ + {name: "latitude out of range", value: "1000", want: `invalid latitude "1000": must be between -90 and 90`}, + {name: "latitude not a number", value: "north", want: "expected a decimal number"}, + {name: "latitude quoted", value: `"37.4"`, want: "expected a decimal number"}, + {name: "latitude infinity", value: "Inf", want: "expected a decimal number"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value := tt.value + if _, err := client.UpdateComputeZone(context.Background(), UpdateComputeZoneOptions{ + ID: "cz-1", + GeoLatitude: &value, + }); err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("unexpected error: got %v want %q", err, tt.want) + } + }) + } + + longitude := "-400" + if _, err := client.UpdateComputeZone(context.Background(), UpdateComputeZoneOptions{ + ID: "cz-1", + GeoLongitude: &longitude, + }); err == nil || !strings.Contains(err.Error(), `invalid longitude "-400": must be between -180 and 180`) { + t.Fatalf("unexpected longitude error: %v", err) + } +} + +// Verifies dry-run previews apply the same backend-preserving merge +func TestPreviewUpdateComputeZone(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("preview should not write, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"computezones":[{"id":"cz-1","type":"datacenter","contact":{"email":"old@example.com","pic":"Ada"}}],"hasMore":false,"page":0,"pageSize":20,"total":1}`)) + })) + defer server.Close() + + client, err := NewClient(server.URL+"/api", "test-key") + if err != nil { + t.Fatalf("new client failed: %v", err) + } + + email := "new@example.com" + preview, err := client.PreviewUpdateComputeZone(context.Background(), UpdateComputeZoneOptions{ + ID: "cz-1", + ContactEmail: &email, + }) + if err != nil { + t.Fatalf("preview failed: %v", err) + } + if preview.Method != http.MethodPut || preview.URL != server.URL+"/api/v1/computezones" { + t.Fatalf("unexpected preview target: %#v", preview) + } + if !strings.Contains(string(preview.Body), `"email":"new@example.com"`) || !strings.Contains(string(preview.Body), `"pic":"Ada"`) { + t.Fatalf("preview body did not preserve contact: %s", string(preview.Body)) + } +} + // Verifies API errors are structured func TestListComputeZonesReturnsAPIError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/nvfleetint/preview.go b/nvfleetint/preview.go new file mode 100644 index 0000000..8730e9f --- /dev/null +++ b/nvfleetint/preview.go @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package nvfleetint + +import ( + "bytes" + "context" + "encoding/json" + "strings" + + "github.com/NVIDIA/fleet-intelligence-client/internal/generated/fleetapi" +) + +// RequestPreview describes the API request a dry-run would send. +type RequestPreview struct { + Method string `json:"method"` + URL string `json:"url"` + Body json.RawMessage `json:"body,omitempty"` +} + +// PreviewUpdateComputeZone returns the update request after applying the same +// read-modify-write merge as UpdateComputeZone, without sending the write. The +// API has no conditional-update mechanism, so a later update can overwrite +// concurrent changes made after this preview's read. +func (c *Client) PreviewUpdateComputeZone(ctx context.Context, opts UpdateComputeZoneOptions) (RequestPreview, error) { + body, err := c.buildUpdateComputeZoneRequest(ctx, opts) + if err != nil { + return RequestPreview{}, err + } + data, err := json.Marshal(body) + if err != nil { + return RequestPreview{}, err + } + + req, err := fleetapi.NewPutV1ComputezonesRequestWithBody( + generatedServerURL(c.baseURL.String()), + "application/json", + bytes.NewReader(data), + ) + if err != nil { + return RequestPreview{}, err + } + + return RequestPreview{ + Method: req.Method, + URL: req.URL.String(), + Body: json.RawMessage(data), + }, nil +} + +// Normalizes a base URL exactly the way fleetapi.NewClient does before the +// generated request builders resolve a path against it. Reusing that same +// string is what keeps a preview URL identical to the URL actually requested, +// including for base URLs carrying a path prefix, query, or fragment. +func generatedServerURL(server string) string { + if strings.HasSuffix(server, "/") { + return server + } + return server + "/" +} diff --git a/nvfleetint/preview_test.go b/nvfleetint/preview_test.go new file mode 100644 index 0000000..afb2eba --- /dev/null +++ b/nvfleetint/preview_test.go @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package nvfleetint + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/NVIDIA/fleet-intelligence-client/internal/generated/fleetapi" +) + +// Records what a write actually put on the wire +type issuedRequest struct { + method string + url string + body string +} + +// Verifies every dry-run preview describes the request the write really sends. +// Add a case here for each new write endpoint. +func TestPreviewsMatchIssuedRequests(t *testing.T) { + // Base URLs ValidateBaseURL accepts but that resolve differently under a + // hand-rolled URL join than under the generated client. + baseSuffixes := []string{"", "/", "/api", "/api/", "/api?x=1", "/api#frag", "/a%2Fb"} + + writes := []struct { + name string + issue func(context.Context, *Client) error + //nolint:revive // preview mirrors issue; both take the same arguments + preview func(context.Context, *Client) (RequestPreview, error) + }{ + { + name: "computezone update", + issue: func(ctx context.Context, client *Client) error { + _, err := client.UpdateComputeZone(ctx, updateComputeZonePreviewOptions()) + return err + }, + preview: func(ctx context.Context, client *Client) (RequestPreview, error) { + return client.PreviewUpdateComputeZone(ctx, updateComputeZonePreviewOptions()) + }, + }, + } + + for _, write := range writes { + for _, suffix := range baseSuffixes { + t.Run(write.name+" base "+suffix, func(t *testing.T) { + var issued *issuedRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"computezones":[{"id":"cz-1","type":"datacenter","contact":{"email":"old@example.com","pic":"Ada"},"geoLocation":{"city":"Santa Clara","country":"US","region":"us-west","latitude":37.774929,"longitude":-122.419416}}],"hasMore":false,"page":0,"pageSize":20,"total":1}`)) + return + } + + data, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body failed: %v", err) + } + issued = &issuedRequest{ + method: r.Method, + // r.URL is request-target only; rebuild the absolute + // URL the preview reports. + url: "http://" + r.Host + r.URL.RequestURI(), + body: string(data), + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"cz-1"}`)) + })) + defer server.Close() + + client, err := NewClient(server.URL+suffix, "test-key") + if err != nil { + t.Fatalf("new client failed: %v", err) + } + + if err := write.issue(context.Background(), client); err != nil { + t.Fatalf("write failed: %v", err) + } + if issued == nil { + t.Fatal("server never saw the write") + } + + preview, err := write.preview(context.Background(), client) + if err != nil { + t.Fatalf("preview failed: %v", err) + } + + if preview.Method != issued.method { + t.Fatalf("preview method %q does not match issued %q", preview.Method, issued.method) + } + if preview.URL != issued.url { + t.Fatalf("preview URL %q does not match issued %q", preview.URL, issued.url) + } + if string(preview.Body) != issued.body { + t.Fatalf("preview body %q does not match issued %q", string(preview.Body), issued.body) + } + }) + } + } +} + +// Builds options that exercise merged and caller-supplied fields alike +func updateComputeZonePreviewOptions() UpdateComputeZoneOptions { + email := "new@example.com" + city := "Austin" + return UpdateComputeZoneOptions{ID: "cz-1", ContactEmail: &email, GeoCity: &city} +} + +// Verifies the preview normalizes a base URL exactly the way the generated +// client does, since that is what makes the previewed URL trustworthy +func TestGeneratedServerURLNormalization(t *testing.T) { + servers := []string{ + "https://fleet.example.com", + "https://fleet.example.com/", + "https://fleet.example.com/api", + "https://fleet.example.com/api/", + "https://fleet.example.com/api?x=1", + "https://fleet.example.com/api#frag", + "http://127.0.0.1:8080", + } + + for _, server := range servers { + t.Run(server, func(t *testing.T) { + generated, err := fleetapi.NewClient(server) + if err != nil { + t.Fatalf("new generated client failed: %v", err) + } + if got := generatedServerURL(server); got != generated.Server { + t.Fatalf("normalized %q, generated client uses %q", got, generated.Server) + } + }) + } +}