From c3137e702876a9556483f86fc2a8ded0b91dc0aa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 13:00:32 +0000 Subject: [PATCH] feat(compute): close the flavor flag gaps against upstream OSC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diffed `koc flavor …` flag-for-flag against python-openstackclient 10.3.0's openstackclient/compute/v2/flavor.py. `flavor set` and `flavor unset` already match upstream exactly; the gaps were all in create, list and show. flavor create gains --property, --project/--project-domain and --description. Nova cannot set extra specs or an access list in the create request, so both follow the POST as they do upstream — but a failure there is returned rather than logged, since the flavor exists and is not the one that was asked for. --project without --private is rejected before the POST instead of leaving a created flavor behind a failed grant, and --public/--private are now mutually exclusive. --id auto was documented as "nova assigns a UUID" and sent verbatim, which would have created a flavor whose ID is the literal string "auto"; it is now translated to an omitted field, matching the novaclient alias upstream still honours. flavor list gains --private (a distinct is_public=false view, not a synonym for the default), --min-disk, --min-ram, --marker and --limit. The three access views are mutually exclusive, and --limit is a hard result cap collected through internal/cli/paging because nova treats it only as a page size. flavor show now renders Access Project IDs. The projects that may boot a private flavor are the whole point of one and live on a separate endpoint; a public flavor has no access list (nova 404s there), so the lookup is skipped and the field stays empty, keeping the field set stable for -c. --rxtx-factor is refused above the microversion that removed the field, but only under an explicit pin. "latest" is resolved by nova, and koc supports clouds back to Zed where it means 2.93 — reading it as "the newest microversion that exists" would refuse the flag on every cloud in the supported range that still accepts it. That asymmetry against the existing lower-bound check is why the two now sit in separate helpers. No command was added, renamed or removed, so docs/coverage.md is unchanged. Exercised end-to-end against a mock nova/keystone: every new flag, both microversion guards and both pre-flight rejections. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HZKL3mxxCBQkj6zxDrFXhD --- internal/cli/compute/flavor.go | 210 +++++++++++++--- internal/cli/compute/flavor_test.go | 366 +++++++++++++++++++++++++++- 2 files changed, 543 insertions(+), 33 deletions(-) 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) +}