Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 178 additions & 32 deletions internal/cli/compute/flavor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 {
Expand All @@ -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))
}

Expand Down Expand Up @@ -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}
Expand All @@ -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 {
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down
Loading