diff --git a/internal/cli/compute/flavor.go b/internal/cli/compute/flavor.go index 0cfff3d..941112c 100644 --- a/internal/cli/compute/flavor.go +++ b/internal/cli/compute/flavor.go @@ -14,6 +14,7 @@ import ( "github.com/ftarasenko/go-openstackclient/internal/auth" "github.com/ftarasenko/go-openstackclient/internal/cli/batchdelete" + "github.com/ftarasenko/go-openstackclient/internal/cli/paging" "github.com/ftarasenko/go-openstackclient/internal/cli/resolve" "github.com/ftarasenko/go-openstackclient/internal/output" ) @@ -46,9 +47,14 @@ func newFlavorCommand(a *auth.Options, o *output.Options) *cobra.Command { // --------------------------------------------------------------------------- type flavorListFlags struct { - long bool - public bool // only public flavors (default view) - all bool // all flavors, public and private (admin) + long bool + public bool // only public flavors (default view) + private bool // only private flavors, across all projects (admin) + all bool // all flavors, public and private (admin) + minDisk int + minRAM int + marker string + limit int } func newFlavorListCommand(a *auth.Options, o *output.Options) *cobra.Command { @@ -72,27 +78,41 @@ func newFlavorListCommand(a *auth.Options, o *output.Options) *cobra.Command { fl := cmd.Flags() fl.BoolVar(&f.long, "long", false, "list additional fields in output") fl.BoolVar(&f.public, "public", false, "list only public flavors (default)") + fl.BoolVar(&f.private, "private", false, "list only private flavors (admin only)") fl.BoolVar(&f.all, "all", false, "list all flavors, whether public or private (admin only)") + fl.IntVar(&f.minDisk, "min-disk", 0, "filter flavors by a minimum root disk size, in GB") + fl.IntVar(&f.minRAM, "min-ram", 0, "filter flavors by a minimum memory size, in MB") + fl.StringVar(&f.marker, "marker", "", "list flavors after this flavor ID (pagination marker)") + fl.IntVar(&f.limit, "limit", 0, "maximum number of flavors to return") + // The three access views select mutually exclusive values of one query + // parameter, so let cobra reject the combination rather than silently + // picking one, as upstream's mutually-exclusive argparse group does. + cmd.MarkFlagsMutuallyExclusive("public", "private", "all") return cmd } func runFlavorList(ctx context.Context, client *gophercloud.ServiceClient, o *output.Options, f *flavorListFlags, w io.Writer) error { - opts := flavors.ListOpts{} + opts := flavors.ListOpts{ + MinDisk: f.minDisk, + MinRAM: f.minRAM, + Marker: f.marker, + Limit: f.limit, + } switch { case f.all: opts.AccessType = flavors.AllAccess + case f.private: + opts.AccessType = flavors.PrivateAccess case f.public: opts.AccessType = flavors.PublicAccess } - pages, err := flavors.ListDetail(client, opts).AllPages(ctx) + // Nova treats limit only as a page size, so --limit is enforced as a hard + // result cap; Collect also stops paging once it is met. + all, err := paging.Collect(ctx, flavors.ListDetail(client, opts), f.limit, flavors.ExtractFlavors) if err != nil { return fmt.Errorf("listing flavors: %w", err) } - all, err := flavors.ExtractFlavors(pages) - if err != nil { - return fmt.Errorf("parsing flavor list: %w", err) - } return o.WriteList(w, flavorListTable(all, f.long)) } @@ -146,9 +166,38 @@ func runFlavorShow(ctx context.Context, client *gophercloud.ServiceClient, o *ou return fmt.Errorf("showing flavor %q: %w", ref, err) } fields, values := flavorSingle(fl) + // A private flavor's whole point is which projects may boot it, and that + // list lives on a separate endpoint. Upstream renders the column + // unconditionally (empty for a public flavor, which has no access list at + // all — nova 404s on the endpoint), so the field set stays stable for -c. + var projects []string + if !fl.IsPublic { + if projects, err = flavorAccessProjectIDs(ctx, client, fl.ID, ref); err != nil { + return err + } + } + fields = append(fields, "Access Project IDs") + values = append(values, projects) return o.WriteSingle(w, fields, values) } +// flavorAccessProjectIDs lists the projects granted access to a private flavor. +func flavorAccessProjectIDs(ctx context.Context, client *gophercloud.ServiceClient, id, ref string) ([]string, error) { + pages, err := flavors.ListAccesses(client, id).AllPages(ctx) + if err != nil { + return nil, fmt.Errorf("listing project access for flavor %q: %w", ref, err) + } + access, err := flavors.ExtractAccesses(pages) + if err != nil { + return nil, fmt.Errorf("parsing project access for flavor %q: %w", ref, err) + } + projects := make([]string, 0, len(access)) + for _, a := range access { + projects = append(projects, a.TenantID) + } + return projects, nil +} + func flavorSingle(fl *flavors.Flavor) ([]string, []any) { fields := []string{"ID", "Name", "RAM", "Disk", "Ephemeral", "VCPUs", "Swap", "RXTX Factor", "Is Public", "Description", "Properties"} values := []any{fl.ID, fl.Name, fl.RAM, fl.Disk, fl.Ephemeral, fl.VCPUs, fl.Swap, fl.RxTxFactor, fl.IsPublic, fl.Description, fl.ExtraSpecs} @@ -159,16 +208,27 @@ func flavorSingle(fl *flavors.Flavor) ([]string, []any) { // flavor create // --------------------------------------------------------------------------- +// flavorRxTxRemovedMicroversion is the compute microversion that removed the +// rxtx_factor field from the flavor API. Above it nova rejects the key, so the +// flag is refused here with the reason rather than letting nova 400. +const flavorRxTxRemovedMicroversion = "2.102" + type flavorCreateFlags struct { - ram int - disk int - vcpus int - id string - ephemeral int - swap int - rxtxFactor float64 - public bool - private bool + ram int + disk int + vcpus int + id string + ephemeral int + swap int + rxtxFactor float64 + rxtxFactorSet bool + public bool + private bool + properties []string + project string + projectDomain string + description string + descriptionSet bool } func newFlavorCreateCommand(a *auth.Options, o *output.Options) *cobra.Command { @@ -181,12 +241,37 @@ func newFlavorCreateCommand(a *auth.Options, o *output.Options) *cobra.Command { if err := o.Validate(); err != nil { return err } + // Both fields are only meaningful when the user asked for them: an + // empty --description is still a description, and --rxtx-factor 0 + // must not be mistaken for "unset" when refusing it above 2.101. + f.descriptionSet = cmd.Flags().Changed("description") + f.rxtxFactorSet = cmd.Flags().Changed("rxtx-factor") ctx := cmd.Context() - client, err := newComputeClient(ctx, a) + client, session, err := newComputeSession(ctx, a) if err != nil { return err } - return runFlavorCreate(ctx, client, o, args[0], f, cmd.OutOrStdout()) + // Nova's access list only exists for a private flavor, so reject the + // combination before creating anything — otherwise the flavor lands + // and only the access grant fails, leaving half the command applied. + if f.project != "" && f.public && !f.private { + return fmt.Errorf("--project requires --private: a public flavor is already reachable by every project") + } + // --project names a keystone project, so it is resolved here rather + // than inside the seam, which stays a pure nova call — same split as + // "flavor set". + projectID := "" + if f.project != "" { + identity, ierr := session.Identity() + if ierr != nil { + return ierr + } + projectID, ierr = resolve.ProjectIDInDomain(ctx, identity, f.project, f.projectDomain) + if ierr != nil { + return ierr + } + } + return runFlavorCreate(ctx, client, o, args[0], f, projectID, cmd.OutOrStdout()) }, } fl := cmd.Flags() @@ -196,21 +281,39 @@ func newFlavorCreateCommand(a *auth.Options, o *output.Options) *cobra.Command { fl.StringVar(&f.id, "id", "", "unique flavor ID; 'auto' or empty lets nova assign a UUID") fl.IntVar(&f.ephemeral, "ephemeral", 0, "ephemeral disk size in GB") fl.IntVar(&f.swap, "swap", 0, "swap space size in MB") - fl.Float64Var(&f.rxtxFactor, "rxtx-factor", 0, "RX/TX factor (default server-side 1.0)") + fl.Float64Var(&f.rxtxFactor, "rxtx-factor", 0, "RX/TX factor (default server-side 1.0; removed from the API at nova "+flavorRxTxRemovedMicroversion+")") fl.BoolVar(&f.public, "public", true, "flavor is available to all projects (default)") - fl.BoolVar(&f.private, "private", false, "flavor is available only to the current project") + fl.BoolVar(&f.private, "private", false, "flavor is available only to the projects granted access") + fl.StringArrayVar(&f.properties, "property", nil, "property to set on the new flavor, as key=value (repeatable)") + fl.StringVar(&f.project, "project", "", "grant this project access to the new flavor (name or ID; requires --private)") + fl.StringVar(&f.projectDomain, "project-domain", "", "domain owning --project, to disambiguate the name (name or ID)") + fl.StringVar(&f.description, "description", "", "flavor description (nova "+flavorDescriptionMicroversion+"+)") + cmd.MarkFlagsMutuallyExclusive("public", "private") return cmd } -func runFlavorCreate(ctx context.Context, client *gophercloud.ServiceClient, o *output.Options, name string, f *flavorCreateFlags, w io.Writer) error { +func runFlavorCreate(ctx context.Context, client *gophercloud.ServiceClient, o *output.Options, name string, f *flavorCreateFlags, projectID string, w io.Writer) error { + specs, err := parseProperties(f.properties) + if err != nil { + return err + } + if f.descriptionSet && !computeSupportsMicroversion(client, flavorDescriptionMicroversion) { + return fmt.Errorf("--description requires compute API microversion %s or later (--os-compute-api-version)", + flavorDescriptionMicroversion) + } + if f.rxtxFactorSet && computePinnedAtOrAbove(client, flavorRxTxRemovedMicroversion) { + return fmt.Errorf("--rxtx-factor is only supported up to compute API microversion 2.101; lower --os-compute-api-version to use it") + } + disk := f.disk opts := flavors.CreateOpts{ - Name: name, - RAM: f.ram, - VCPUs: f.vcpus, - Disk: &disk, - ID: f.id, - RxTxFactor: f.rxtxFactor, + Name: name, + RAM: f.ram, + VCPUs: f.vcpus, + Disk: &disk, + ID: flavorCreateID(f.id), + RxTxFactor: f.rxtxFactor, + Description: f.description, } if f.ephemeral != 0 { eph := f.ephemeral @@ -229,10 +332,39 @@ func runFlavorCreate(ctx context.Context, client *gophercloud.ServiceClient, o * if err != nil { return fmt.Errorf("creating flavor %q: %w", name, err) } + // Nova has no way to create a flavor with its access list or extra specs in + // the same call, so both follow the POST — as they do upstream. A failure + // here is reported rather than logged: the flavor exists but is not the one + // that was asked for, and an exit code is the only way a script sees that. + if projectID != "" { + if _, aerr := flavors.AddAccess(ctx, client, fl.ID, flavors.AddAccessOpts{Tenant: projectID}).Extract(); aerr != nil { + return fmt.Errorf("granting project %q access to flavor %q: %w", projectID, name, aerr) + } + } + if len(specs) > 0 { + created, serr := flavors.CreateExtraSpecs(ctx, client, fl.ID, flavors.ExtraSpecsOpts(specs)).Extract() + if serr != nil { + return fmt.Errorf("setting properties on flavor %q: %w", name, serr) + } + // The create response predates the extra specs, so fold them in rather + // than rendering a flavor whose Properties column is empty. + fl.ExtraSpecs = created + } fields, values := flavorSingle(fl) return o.WriteSingle(w, fields, values) } +// flavorCreateID maps the --id value onto nova's request field. novaclient +// aliased "auto" to "generate a UUID for me" and upstream OSC still honours it +// (with a deprecation warning), so an "auto" that reached nova verbatim would +// create a flavor literally named by that ID. +func flavorCreateID(id string) string { + if id == "auto" { + return "" + } + return id +} + // --------------------------------------------------------------------------- // flavor delete // --------------------------------------------------------------------------- @@ -599,10 +731,24 @@ func resolveFlavorID(ctx context.Context, client *gophercloud.ServiceClient, ref // computeSupportsMicroversion reports whether the compute client's negotiated // microversion is at least want. "latest" (koc's default) supports everything; // an unset microversion is nova's 2.1 baseline and supports nothing newer. +// +// It backs the *lower* bounds — a feature nova added — where "latest" resolving +// to something older on an old cloud is harmless: nova answers with its own +// error and no flag is refused that the cloud would have taken. func computeSupportsMicroversion(client *gophercloud.ServiceClient, want string) bool { - if client.Microversion == "latest" { - return true - } + return client.Microversion == "latest" || computePinnedAtOrAbove(client, want) +} + +// computePinnedAtOrAbove reports whether the client is *pinned* to a +// microversion at or above want, answering false for "latest". +// +// It backs the *upper* bounds — a field nova removed — and the asymmetry with +// computeSupportsMicroversion is deliberate. "latest" is resolved by nova, not +// here, and koc supports clouds back to Zed, where it means 2.93; reading it as +// "the newest microversion that exists" would refuse --rxtx-factor on every +// cloud in the supported range that still accepts it. Only an explicit pin is +// evidence that the field is gone. +func computePinnedAtOrAbove(client *gophercloud.ServiceClient, want string) bool { hMaj, hMin, ok := parseMicroversion(client.Microversion) if !ok { return false diff --git a/internal/cli/compute/flavor_test.go b/internal/cli/compute/flavor_test.go index 6f7f4f0..c386620 100644 --- a/internal/cli/compute/flavor_test.go +++ b/internal/cli/compute/flavor_test.go @@ -153,7 +153,7 @@ func TestRunFlavorCreate_RequestBodyAndOutput(t *testing.T) { f := &flavorCreateFlags{ram: 512, disk: 1, vcpus: 1, public: true} var buf bytes.Buffer - if err := runFlavorCreate(context.Background(), client, o, "m1.custom", f, &buf); err != nil { + if err := runFlavorCreate(context.Background(), client, o, "m1.custom", f, "", &buf); err != nil { t.Fatalf("runFlavorCreate returned error: %v", err) } @@ -942,3 +942,367 @@ func TestRunFlavorUnset_PropertiesAndProject(t *testing.T) { t.Errorf("calls = %v, want %v", calls, want) } } + +// TestRunFlavorList_PrivateAccessFilter covers "flavor list --private", which +// nova selects with is_public=false — a distinct view from both the default +// (public plus the caller's own) and --all. +func TestRunFlavorList_PrivateAccessFilter(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/flavors/detail", func(w http.ResponseWriter, r *http.Request) { + th.TestFormValues(t, r, map[string]string{"is_public": "false"}) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"flavors": []}`)) + }) + + client := computeClient(fakeServer, "latest") + o := &output.Options{Format: output.FormatValue} + + var buf bytes.Buffer + if err := runFlavorList(context.Background(), client, o, &flavorListFlags{private: true}, &buf); err != nil { + t.Fatalf("runFlavorList returned error: %v", err) + } +} + +// TestRunFlavorList_MinDiskMinRAMAndPaging asserts the server-side filters and +// the pagination parameters reach nova as query strings. +func TestRunFlavorList_MinDiskMinRAMAndPaging(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/flavors/detail", func(w http.ResponseWriter, r *http.Request) { + th.TestFormValues(t, r, map[string]string{ + "minDisk": "20", + "minRam": "2048", + "marker": "1", + "limit": "1", + }) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(flavorListBody)) + }) + + client := computeClient(fakeServer, "latest") + o := &output.Options{Format: output.FormatValue} + + f := &flavorListFlags{minDisk: 20, minRAM: 2048, marker: "1", limit: 1} + var buf bytes.Buffer + if err := runFlavorList(context.Background(), client, o, f, &buf); err != nil { + t.Fatalf("runFlavorList returned error: %v", err) + } + + // Nova treats limit as a page size, so the cap is enforced client-side too: + // the fixture holds two flavors and --limit 1 must render exactly one row. + if lines := strings.Count(strings.TrimSpace(buf.String()), "\n") + 1; lines != 1 { + t.Errorf("rendered %d rows, want 1:\n%s", lines, buf.String()) + } +} + +// TestRunFlavorShow_PrivateListsAccessProjects asserts "flavor show" pulls the +// access list for a private flavor — the projects that may boot it live on a +// separate endpoint and are the whole point of a private flavor. +func TestRunFlavorShow_PrivateListsAccessProjects(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/flavors/detail", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(flavorListBody)) + }) + fakeServer.Mux.HandleFunc("/flavors/2", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "flavor": { + "id": "2", + "name": "m1.small", + "ram": 2048, + "disk": 20, + "vcpus": 1, + "OS-FLV-EXT-DATA:ephemeral": 0, + "swap": "", + "rxtx_factor": 1.0, + "os-flavor-access:is_public": false + } +}`)) + }) + var gotAccessMethod, gotAccessPath string + fakeServer.Mux.HandleFunc("/flavors/2/os-flavor-access", func(w http.ResponseWriter, r *http.Request) { + gotAccessMethod = r.Method + gotAccessPath = r.URL.Path + th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"flavor_access": [ + {"flavor_id": "2", "tenant_id": "0f1e2d3c4b5a69788796a5b4c3d2e1f0"}, + {"flavor_id": "2", "tenant_id": "1a2b3c4d5e6f70819283a4b5c6d7e8f9"} + ]}`)) + }) + + client := computeClient(fakeServer, "2.61") + o := &output.Options{Format: output.FormatTable} + + var buf bytes.Buffer + if err := runFlavorShow(context.Background(), client, o, "m1.small", &buf); err != nil { + t.Fatalf("runFlavorShow returned error: %v", err) + } + + if gotAccessMethod != http.MethodGet { + t.Errorf("access request method = %q, want GET", gotAccessMethod) + } + if gotAccessPath != "/flavors/2/os-flavor-access" { + t.Errorf("access request path = %q, want /flavors/2/os-flavor-access", gotAccessPath) + } + out := buf.String() + for _, want := range []string{"Access Project IDs", "0f1e2d3c4b5a69788796a5b4c3d2e1f0", "1a2b3c4d5e6f70819283a4b5c6d7e8f9"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q\n---\n%s", want, out) + } + } +} + +// TestRunFlavorShow_PublicSkipsAccessLookup asserts a public flavor does not hit +// the access endpoint: nova has no access list for one, and a request would 404. +func TestRunFlavorShow_PublicSkipsAccessLookup(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/flavors/detail", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(flavorListBody)) + }) + fakeServer.Mux.HandleFunc("/flavors/1", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(flavorGetBody)) + }) + // No /flavors/1/os-flavor-access handler: a request there would 404 and fail. + + client := computeClient(fakeServer, "2.61") + o := &output.Options{Format: output.FormatTable} + + var buf bytes.Buffer + if err := runFlavorShow(context.Background(), client, o, "1", &buf); err != nil { + t.Fatalf("runFlavorShow returned error: %v", err) + } + if !strings.Contains(buf.String(), "Access Project IDs") { + t.Errorf("output missing the Access Project IDs field:\n%s", buf.String()) + } +} + +// TestRunFlavorCreate_PropertiesAndProjectAccess covers the two follow-up calls +// nova forces after the POST: the flavor's access list and its extra specs +// cannot be set in the create request. +func TestRunFlavorCreate_PropertiesAndProjectAccess(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + var createBody map[string]any + fakeServer.Mux.HandleFunc("/flavors", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &createBody); err != nil { + t.Errorf("decoding request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "flavor": { + "id": "abc", + "name": "m1.private", + "ram": 512, + "disk": 1, + "vcpus": 1, + "OS-FLV-EXT-DATA:ephemeral": 0, + "swap": "", + "os-flavor-access:is_public": false, + "description": "team flavor" + } +}`)) + }) + var accessBody map[string]any + fakeServer.Mux.HandleFunc("/flavors/abc/action", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &accessBody); err != nil { + t.Errorf("decoding access body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"flavor_access": [{"flavor_id": "abc", "tenant_id": "0f1e2d3c4b5a69788796a5b4c3d2e1f0"}]}`)) + }) + var specsMethod string + var specsBody map[string]any + fakeServer.Mux.HandleFunc("/flavors/abc/os-extra_specs", func(w http.ResponseWriter, r *http.Request) { + specsMethod = r.Method + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &specsBody); err != nil { + t.Errorf("decoding extra specs body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"extra_specs": {"hw:cpu_policy": "dedicated"}}`)) + }) + + client := computeClient(fakeServer, "latest") + o := &output.Options{Format: output.FormatTable} + + f := &flavorCreateFlags{ + ram: 512, disk: 1, vcpus: 1, private: true, + properties: []string{"hw:cpu_policy=dedicated"}, + project: "engineering", + description: "team flavor", + descriptionSet: true, + } + var buf bytes.Buffer + if err := runFlavorCreate(context.Background(), client, o, "m1.private", f, "0f1e2d3c4b5a69788796a5b4c3d2e1f0", &buf); err != nil { + t.Fatalf("runFlavorCreate returned error: %v", err) + } + + flavorBody, ok := createBody["flavor"].(map[string]any) + if !ok { + t.Fatalf("request body missing 'flavor' object: %#v", createBody) + } + if flavorBody["description"] != "team flavor" { + t.Errorf("body description = %v, want %q", flavorBody["description"], "team flavor") + } + if pub, ok := flavorBody["os-flavor-access:is_public"].(bool); !ok || pub { + t.Errorf("body is_public = %v, want false", flavorBody["os-flavor-access:is_public"]) + } + access, ok := accessBody["addTenantAccess"].(map[string]any) + if !ok { + t.Fatalf("access body missing 'addTenantAccess' object: %#v", accessBody) + } + if access["tenant"] != "0f1e2d3c4b5a69788796a5b4c3d2e1f0" { + t.Errorf("addTenantAccess.tenant = %v, want the resolved project ID", access["tenant"]) + } + if specsMethod != http.MethodPost { + t.Errorf("extra specs method = %q, want POST", specsMethod) + } + specs, ok := specsBody["extra_specs"].(map[string]any) + if !ok { + t.Fatalf("extra specs body missing 'extra_specs' object: %#v", specsBody) + } + if specs["hw:cpu_policy"] != "dedicated" { + t.Errorf("extra_specs[hw:cpu_policy] = %v, want dedicated", specs["hw:cpu_policy"]) + } + // The create response predates the extra specs, so they are folded into the + // rendered flavor rather than shown as an empty Properties column. + if !strings.Contains(buf.String(), "hw:cpu_policy") { + t.Errorf("output missing the properties set after create:\n%s", buf.String()) + } +} + +// TestRunFlavorCreate_IDAutoIsOmitted asserts the novaclient "auto" alias is +// translated rather than sent verbatim, which would name the flavor "auto". +func TestRunFlavorCreate_IDAutoIsOmitted(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + var gotBody map[string]any + fakeServer.Mux.HandleFunc("/flavors", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &gotBody); err != nil { + t.Errorf("decoding request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"flavor": {"id": "abc", "name": "m1.custom", "ram": 512, "disk": 1, "vcpus": 1, "os-flavor-access:is_public": true}}`)) + }) + + client := computeClient(fakeServer, "latest") + o := &output.Options{Format: output.FormatValue} + + f := &flavorCreateFlags{ram: 512, disk: 1, vcpus: 1, public: true, id: "auto"} + var buf bytes.Buffer + if err := runFlavorCreate(context.Background(), client, o, "m1.custom", f, "", &buf); err != nil { + t.Fatalf("runFlavorCreate returned error: %v", err) + } + + flavorBody, ok := gotBody["flavor"].(map[string]any) + if !ok { + t.Fatalf("request body missing 'flavor' object: %#v", gotBody) + } + if id, present := flavorBody["id"]; present { + t.Errorf("body id = %v, want the key omitted so nova assigns a UUID", id) + } +} + +// TestRunFlavorCreate_DescriptionRequiresMicroversion and its rxtx sibling assert +// both version gates fail before the POST, so no flavor is created at all. +func TestRunFlavorCreate_DescriptionRequiresMicroversion(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + // No /flavors handler: reaching nova at all is the failure this asserts. + + client := computeClient(fakeServer, "2.1") + o := &output.Options{Format: output.FormatValue} + + f := &flavorCreateFlags{ram: 512, disk: 1, vcpus: 1, public: true, description: "x", descriptionSet: true} + var buf bytes.Buffer + err := runFlavorCreate(context.Background(), client, o, "m1.custom", f, "", &buf) + if err == nil { + t.Fatal("runFlavorCreate returned nil error; want a microversion rejection") + } + if !strings.Contains(err.Error(), flavorDescriptionMicroversion) { + t.Errorf("error = %v, want it to name microversion %s", err, flavorDescriptionMicroversion) + } +} + +func TestRunFlavorCreate_RxTxFactorRejectedAboveRemoval(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + // No /flavors handler, for the reason above. + + // An explicit pin, not "latest": the guard is deliberately blind to "latest" + // because nova resolves it, and on Zed it means 2.93 — where the field still + // exists. See computePinnedAtOrAbove. + client := computeClient(fakeServer, flavorRxTxRemovedMicroversion) + o := &output.Options{Format: output.FormatValue} + + f := &flavorCreateFlags{ram: 512, disk: 1, vcpus: 1, public: true, rxtxFactor: 2, rxtxFactorSet: true} + var buf bytes.Buffer + err := runFlavorCreate(context.Background(), client, o, "m1.custom", f, "", &buf) + if err == nil { + t.Fatal("runFlavorCreate returned nil error; want an rxtx-factor rejection") + } + if !strings.Contains(err.Error(), "rxtx-factor") { + t.Errorf("error = %v, want it to name --rxtx-factor", err) + } +} + +// TestRunFlavorCreate_RxTxFactorAllowedUnderLatest is the other half of the +// guard: koc's default microversion is "latest", which on a Zed cloud is 2.93, +// so --rxtx-factor must still reach nova and let the cloud decide. +func TestRunFlavorCreate_RxTxFactorAllowedUnderLatest(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + var gotBody map[string]any + fakeServer.Mux.HandleFunc("/flavors", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &gotBody); err != nil { + t.Errorf("decoding request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"flavor": {"id": "abc", "name": "m1.custom", "ram": 512, "disk": 1, "vcpus": 1, "rxtx_factor": 2.0, "os-flavor-access:is_public": true}}`)) + }) + + client := computeClient(fakeServer, "latest") + o := &output.Options{Format: output.FormatValue} + + f := &flavorCreateFlags{ram: 512, disk: 1, vcpus: 1, public: true, rxtxFactor: 2, rxtxFactorSet: true} + var buf bytes.Buffer + if err := runFlavorCreate(context.Background(), client, o, "m1.custom", f, "", &buf); err != nil { + t.Fatalf("runFlavorCreate returned error: %v", err) + } + + flavorBody, ok := gotBody["flavor"].(map[string]any) + if !ok { + t.Fatalf("request body missing 'flavor' object: %#v", gotBody) + } + assertJSONNum(t, flavorBody, "rxtx_factor", 2) +}